From 0.8.0 a component can describe its HTTP API once, as a catalog, and get from it:
@karmaniverous/jeeves/fastify);@karmaniverous/jeeves/openapi);@karmaniverous/jeeves/catalog);createPluginToolset from the root entry point).Adoption is optional and per component. A component that doesn't adopt keeps working unchanged. This guide walks through adopting it, package by package, in the usual three-package layout: core, service and openclaw.
| Package | Add |
|---|---|
core |
@karmaniverous/jeeves (already there), zod ^4.6; zod-openapi ^6 as a dev dependency (writes openapi.json) |
service |
fastify ^5 (your app) |
openclaw |
nothing new |
fastify and zod-openapi are optional peers of @karmaniverous/jeeves: /fastify needs fastify (types; your app's own instance does the work) and /openapi needs zod-openapi. The root and /catalog need neither, so an SDK built on createCatalogClient stays light. Core validates requests itself; you don't need fastify-type-provider-zod unless the service uses it for its own routes outside the catalog.
Use one copy of Zod: catalog schemas are built with your zod and core reads them, so declare zod as ^4.6 wherever you declare it, and check npm ls zod shows a single version.
coreimport {
defineCatalog,
queryArray,
standardEntries,
} from '@karmaniverous/jeeves/catalog';
import { z } from 'zod';
const bearer = { schemes: [{ in: 'bearer' }] } as const;
export const catalog = defineCatalog([
...standardEntries('acme', {
auth: { config: bearer },
healthSchema: z.looseObject({ indexed: z.number() }),
}),
{
name: 'search',
method: 'POST',
path: '/search',
description: 'Search the archive.',
body: z.object({ query: z.string(), limit: z.number().optional() }),
response: z.object({ hits: z.array(z.string()) }),
auth: bearer,
kind: 'read',
},
{
name: 'listTags',
method: 'GET',
path: '/tags',
description: 'List tags, optionally filtered.',
query: z.object({ prefix: queryArray(z.string()).optional() }),
response: z.object({ tags: z.array(z.string()) }),
auth: 'none',
kind: 'read',
},
]);
Each entry has a camelCase name, a method, a Fastify-syntax path (:param, or * for the rest of the path), a description, optional params/query/body Zod objects, a response schema, an auth description and a kind (read, write or destructive). Non-JSON entries set contentType and respond with z.instanceof(Uint8Array) or z.string(). A content type's media type is lowercase (parameters such as charset=UTF-8 may not be), and it contains json, in lowercase, exactly when it is JSON: a json subtype or a +json suffix, such as application/json, application/problem+json or application/json-seq.
defineCatalog checks the rules MCP and the tools rely on, and throws CatalogDefinitionError listing every violation:
method + path pairs are unique, ignoring parameter names (GET /a/:x and GET /a/:y are the same route);params keys equal the path's parameters; no field appears in more than one of params, query and body;z.coerce.*, z.stringbool() or queryArray(item), because Fastify delivers query values as strings and a single repeated key as a string, not an array;z.codec) in request schemas rather than z.transform (which doesn't convert) or z.preprocess (which misdescribes the wire shape).Standard entries. standardEntries(name, { auth: { config, status? }, healthSchema? }) describes the status, config and configApply routes every component already has, with the component's name in their descriptions. Remove your own entries for those routes: a duplicate name or route is rejected. auth.config is required: say how your /config routes are protected today. status defaults to 'none'.
auth describes; you enforce. An entry's auth lists the credentials the route accepts (bearer, or a named header, query or cookie; insiderOnly for jeeves-server). It feeds the OpenAPI security schemes and tells the client and tools which credential to attach. It doesn't protect the route: your own auth hook still does.
coreExport the always-on rules text and the skill from core as well, and put them on the descriptor:
export const descriptor = jeevesComponentDescriptorSchema.parse({
// ...name, version, ports, configSchema, etc.
promptRules, // always-on rules text
skill, // contents of SKILL.md
});
Core registers neither. The plugin passes promptRules to registerPromptContext, and the plugin's build writes skill as SKILL.md into the folder its openclaw.plugin.json lists under skills (for the watcher, dist/skills/jeeves-watcher). Keeping both in core lets the plugin and the service reach them without resolving paths into another package.
coreCommit the generated document, rewrite it from a build script, and fail a test when it drifts:
// scripts/write-openapi.ts
import { writeOpenApiDocument } from '@karmaniverous/jeeves/openapi';
import { catalog } from '../src/catalog.js';
writeOpenApiDocument(
catalog,
{ title: 'acme', version: '1.0.0' },
'openapi.json',
);
// src/openapi.test.ts
import { checkOpenApiDrift } from '@karmaniverous/jeeves/openapi';
import { expect, it } from 'vitest';
import { catalog } from './catalog.js';
it('openapi.json matches the catalog', () => {
const result = checkOpenApiDrift(
catalog,
{ title: 'acme', version: '1.0.0' },
'openapi.json',
);
expect(result.diff).toBeUndefined();
expect(result.ok).toBe(true);
});
Neither needs Fastify. buildOpenApiDocument returns the document if you want it in memory.
serviceimport {
registerCatalogRoutes,
registerOpenApiRoute,
standardHandlers,
} from '@karmaniverous/jeeves/fastify';
// 1. Your auth hook first, on the root app, exempting /openapi.json.
app.addHook('onRequest', authHook);
// 2. Then the catalog routes.
await registerCatalogRoutes(app, catalog, {
...standardHandlers({
status: createStatusHandler({/* ... */}),
config: createConfigQueryHandler(() => getConfig()),
configApply: createConfigApplyHandler(descriptor),
}),
search: async ({ body }) => ({ status: 200, body: await search(body) }),
listTags: async ({ query }) => ({
status: 200,
body: { tags: await tags(query.prefix ?? []) },
}),
});
// 3. The committed document, unauthenticated.
registerOpenApiRoute(app, openApiDocument);
{ params, query, body } (parsed by the catalog schemas) and the Fastify request, and return { status, body, headers? }. A missing or extra handler is a type error. Errors return a 4xx/5xx status with an { error: string, ... } body.registerCatalogRoutes. Test it: each protected entry without credentials gets 401; each 'none' entry doesn't.{ error: 'Request validation failed', issues }. Pass formatValidationError to return your own body (the watcher keeps its code field). Each issue has slot, path, code, message and params, the Zod issue's other fields (minimum, expected, keys, ...).response schema (and the error schema for 4xx/5xx). With responseValidation: 'strict', the default, a mismatch is logged at error and replaced with 500 { code: 'internal_error', error: 'Response validation failed' }. Zod also drops object keys a schema doesn't name, so name every field, or use z.looseObject. Mismatches are logged through Fastify's logger; if your app runs Fastify with logger: false and logs elsewhere, pass logger (anything with pino-style warn(details, message) and error(details, message)). While migrating, pass responseValidation: 'log': a mismatch is logged at warn and the handler's body is sent unchanged. Add a test that each serialised response equals the handler's body, then switch to 'strict'.registerCatalogRoutes promise.import { createCatalogClient } from '@karmaniverous/jeeves/catalog';
const client = createCatalogClient(catalog, {
baseUrl: 'http://127.0.0.1:1936',
auth: { bearer: () => process.env.ACME_API_KEY },
});
const { hits } = await client.search({ query: 'invoices', limit: 5 });
One method per entry, taking the merged params/query/body input (optional when no field is required) and returning the parsed response. A z.looseObject body also sends input fields no slot names; a z.strictObject body rejects them before sending. Credentials attach only to entries whose auth lists a scheme of the same kind; a lazy secret returning undefined attaches nothing. Failures throw CatalogClientError: status 0 with issues for invalid input, the HTTP status and parsed error body for non-2xx, status 0 with cause for network errors, timeouts (including while the body is arriving), malformed JSON and a failing credential resolver. An error built from a response (non-2xx, or a 2xx body that fails its schema) also carries the response headers and retryAfterMs, parsed from Retry-After (seconds or an HTTP date; absent when missing or unparseable), so an SDK can back off on a 429 or 503. It isn't capped: cap it before passing it to setTimeout, which fires delays above about 24.8 days after 1 ms.
openclawimport { createPluginToolset } from '@karmaniverous/jeeves';
const tools = createPluginToolset(descriptor, {
apiUrl: () => resolveApiUrl(api),
catalog,
tools: {
search: { catalogMode: 'direct-only' },
listTags: true,
},
auth: { bearer: () => readApiKey(api) },
api,
});
acme_status, acme_config, acme_config_apply, acme_service) are built from the catalog's standard entries, keep their 0.7.0 names, descriptions and results, and gain replace on config_apply.tools selects catalog entries to expose, in catalog order; unselected entries get no tool. Per entry you can override the description, rename fields with inputAliases ({ '*': 'path' }) and set catalogMode: 'direct-only' to keep a tool visible to the model when OpenClaw's Tool Search defers other tools.auth is attached as in the client. Without a catalog, no credentials are sent, as in 0.7.0.api, the descriptor's customPluginTools(api) are appended. Duplicate tool names throw.Parameters are generated from the schemas, so they include keywords such as additionalProperties: false; the model can't pass parameters a route doesn't accept.
zod in npm ls zod.core with standardEntries; your own status/config/configApply entries removed.promptRules and skill exported from core and on the descriptor; the plugin build writes SKILL.md.openapi.json committed; build script writes it; drift test passes.registerCatalogRoutes, exempting /openapi.json; 401 tests per protected entry.responseValidation: 'strict'.