From 8cf6f83407a43ef1839ff6259a4dd9abfc30a505 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 14:31:20 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(client,spec):=20`ai.agents.*`=20and=20?= =?UTF-8?q?`ai.pendingActions.*`=20=E2=80=94=20the=20AI=20routes=20the=20S?= =?UTF-8?q?DK=20could=20not=20reach=20(#3718)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #3718 deleted three `client.ai.*` methods whose URLs no route had ever mounted, then expressed the surface that does exist. It expressed ONE builder's worth of it. `service-ai` mounts seven; widening its ledger (objectstack-ai/cloud#903) counted ten routes the SDK cannot reach, nine of which had never been counted. This closes the six with the strongest evidence: objectui already ships product on them, over URLs it hand-builds because there is nothing to call. ai.agents — `/ai/chat` talks to the default agent; these talk to a named one. list() — agents this CALLER may chat with; the route filters by permission (ADR-0049), so empty is a legitimate answer, not an error to retry chat(name, req) — forces `stream: false`, same reason `ai.chat` does: the route streams by default chatStream(name, req) — same route, streaming mode. One route, two methods, mirroring chat/chatStream rather than inventing a third shape ai.pendingActions — the HITL approval queue an embedding app must render. list(options?) — status/conversationId/limit ONLY. The service also accepts objectName; the route never forwards it, so typing it would offer a filter that silently does nothing get(id) approve(id) — approves AND executes. `{status:'failed'}` comes back on HTTP 200: the approval succeeded, the execution did not. Reading only `res.ok` reports a failed write as a success reject(id, reason?) — executes nothing Typed from what the routes RETURN, not from what a client might like them to — the failure #3718 exists to punish. Pending actions are the persisted row, snake_case on the wire because that is what it is; agent rows require `capabilities` because that object is what tells a UI what to render. The capstone's `/api/v1/ai/` prefix exemption says the evidence lives across the repo boundary. It does, and it now reaches these: cloud's ledger drives every `ai.*` method against the tables its builders really return, and since #903 that means all seven. Comment updated there — it still described the one-builder version, under which `buildAgentRoutes()` and `buildPendingActionRoutes()` were invisible. Verification: client 184/184, spec 6836/6836 (24 in protocol.test.ts, extended with the new shapes incl. the negative cases — an agent row without capabilities, a non-enum status filter, `approve` yielding "rejected"). Mutation-checked: pointing `ai.pendingActions.get` off the AI prefix fails the capstone by name, proving the new methods are really in its sweep rather than silently absent. Generated artifacts regenerated: api-surface.json (+20 exports, 0 breaking) and json-schema.manifest.json. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WJX6GnuNix7HisBc92THMN --- .changeset/ai-agents-pending-actions-sdk.md | 60 ++++++++ .../client/src/client-url-conformance.test.ts | 19 ++- packages/client/src/index.ts | 129 ++++++++++++++++++ packages/spec/api-surface.json | 20 +++ packages/spec/json-schema.manifest.json | 10 ++ packages/spec/src/api/protocol.test.ts | 54 ++++++++ packages/spec/src/api/protocol.zod.ts | 126 +++++++++++++++++ 7 files changed, 412 insertions(+), 6 deletions(-) create mode 100644 .changeset/ai-agents-pending-actions-sdk.md diff --git a/.changeset/ai-agents-pending-actions-sdk.md b/.changeset/ai-agents-pending-actions-sdk.md new file mode 100644 index 0000000000..920a3e9bea --- /dev/null +++ b/.changeset/ai-agents-pending-actions-sdk.md @@ -0,0 +1,60 @@ +--- +"@objectstack/spec": minor +"@objectstack/client": minor +--- + +feat(client,spec): `ai.agents.*` and `ai.pendingActions.*` — the AI routes the SDK could not reach (#3718) + +#3718 deleted three `client.ai.*` methods whose URLs no route had ever mounted, +then expressed the surface that does exist. It expressed **one** builder's worth +of it. `service-ai` mounts seven; the audit that widened its ledger +(objectstack-ai/cloud#903) counted **ten** routes the SDK cannot reach, nine of +which had simply never been counted. + +This closes the six with the strongest evidence: `objectui` already ships +product on them, over URLs it builds by hand because there was nothing to call. + +**`ai.agents`** — `/ai/chat` talks to the environment's default agent; these +talk to one you name. + +- `agents.list()` — the agents this CALLER may chat with. The route filters by + the caller's permissions (ADR-0049), so an empty list is a legitimate answer + for a seat-less user, not an error to retry. +- `agents.chat(name, request)` / `agents.chatStream(name, request)` — one route, + two methods, mirroring `ai.chat` / `ai.chatStream` rather than inventing a + third shape for the same endpoint. `chat` forces `stream: false` for the same + reason `ai.chat` does: the route streams by default, so leaving the flag to + the caller means the JSON path is the one you have to remember. + +**`ai.pendingActions`** — the human-in-the-loop approval queue. When a tool call +needs a human decision the turn parks an action instead of executing it, and an +app embedding the chat has to render and resolve that queue. + +- `pendingActions.list(options?)` — `status`, `conversationId` and `limit` only. + `AIService.listPendingActions` also accepts `objectName`, but the route never + forwards it; typing it here would offer a filter that silently does nothing. +- `pendingActions.get(id)` +- `pendingActions.approve(id)` — approves **and executes**. Check the returned + `status`: a tool that fails after approval comes back + `{ status: 'failed', error }` with HTTP 200, because the approval succeeded + even though the execution did not. Code that reads only `res.ok` reports a + failed write as a success. +- `pendingActions.reject(id, reason?)` — executes nothing. + +Reads and decisions are separately permissioned server-side (`ai:read` vs +`ai:approve`), so a caller that can list the queue may still be refused on +approve. Handle the 403; one does not imply the other. + +**Typed from what the routes return**, not from what a client might like them +to — the failure #3718 exists to punish. The pending-action shape is the +persisted row, `snake_case` on the wire because that is what it is. Agent rows +require `capabilities`, because that object is what tells a UI which +affordances to render. + +The capstone (#3642) exempts `/api/v1/ai/` by prefix and says the evidence lives +on the other side of the repo boundary. It does: cloud's ledger drives every +`ai.*` method against the tables its builders really return — and since #903 +that means all seven builders, which is what makes these six routes checkable +at all. Their routes come from `buildAgentRoutes()` and +`buildPendingActionRoutes()`, neither of which the ledger could see when the +exemption was written. diff --git a/packages/client/src/client-url-conformance.test.ts b/packages/client/src/client-url-conformance.test.ts index 311042e694..44e508db8a 100644 --- a/packages/client/src/client-url-conformance.test.ts +++ b/packages/client/src/client-url-conformance.test.ts @@ -164,15 +164,22 @@ const CONTROL_PLANE_NAMESPACE = 'projects.'; * counted as matched when not one of their URLs was in the real table at all. * Three SDK methods 404ed for years behind a green row (#3718). v17 deleted * them; #3718 then expressed the surface that does exist (`ai.chat`, - * `ai.chatStream`, `ai.complete`, `ai.models`, `ai.conversations.*`), which is - * why an exemption is needed again. + * `ai.chatStream`, `ai.complete`, `ai.models`, `ai.conversations.*`, and since + * the follow-up pass `ai.agents.*` and `ai.pendingActions.*`), which is why an + * exemption is needed again. * * It is NOT a wave-through, for the same reason the control plane's is not: * `cloud`'s `packages/service-ai/src/ai-route-ledger.conformance.test.ts` - * reads the table `buildAIRoutes()` returns and drives every `ai.*` method on - * this very SDK against it — so an `ai.*` URL that stops resolving fails a - * test in the repo that mounts the route. What this exemption says is "the - * evidence lives on the other side of the boundary", not "no evidence needed". + * drives every `ai.*` method on this very SDK against the tables its route + * builders really return — so an `ai.*` URL that stops resolving fails a test + * in the repo that mounts the route. What this exemption says is "the evidence + * lives on the other side of the boundary", not "no evidence needed". + * + * That ledger covered ONE builder when this comment was first written. It now + * covers all seven plus the family's one out-of-package member, which is what + * makes `ai.agents.*` and `ai.pendingActions.*` checkable at all: their routes + * are mounted by `buildAgentRoutes()` and `buildPendingActionRoutes()`, not by + * `buildAIRoutes()`. * * Bounded from both ends below: only `ai.*` may use it, and the namespace must * still be reaching it. diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index d0862a679b..4aec1e47a1 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -43,6 +43,14 @@ import { ListAiConversationsRequest, ListAiConversationsResponse, UpdateAiConversationRequest, + AiAgentSummary, + AiAgentsResponse, + AiAgentChatRequest, + AiPendingAction, + ListAiPendingActionsRequest, + ListAiPendingActionsResponse, + ApproveAiPendingActionResponse, + RejectAiPendingActionResponse, GetLocalesResponse, GetTranslationsResponse, GetFieldLabelsResponse, @@ -3718,6 +3726,119 @@ export class ObjectStackClient { return this.unwrapResponse(res); }, }, + + /** + * Named agents. + * + * `/ai/chat` talks to the environment's default agent; these talk to one + * you name. Both routes have been mounted since long before this namespace + * existed — `objectui` hand-built their URLs in five places because the SDK + * offered nothing to call (#3718). + */ + agents: { + /** + * Agents the CALLER may chat with — the route filters by the caller's + * permissions (ADR-0049), so an empty list is a legitimate answer for a + * seat-less user rather than an error to retry. + */ + list: async (): Promise => { + const route = this.getRoute('ai'); + const res = await this.fetch(`${this.baseUrl}${route}/agents`); + const body = await this.unwrapResponse(res); + return body?.agents ?? []; + }, + + /** + * Chat with a named agent, returned as JSON. + * + * Sends `stream: false` for the same reason `ai.chat` does — the route + * streams by default, so the flag is forced here rather than left to the + * caller to remember. + */ + chat: async (agentName: string, request: AiAgentChatRequest): Promise => { + const route = this.getRoute('ai'); + const res = await this.fetch(`${this.baseUrl}${route}/agents/${encodeURIComponent(agentName)}/chat`, { + method: 'POST', + body: JSON.stringify({ ...request, stream: false }), + }); + return this.unwrapResponse(res); + }, + + /** + * The streaming twin of {@link chat} — same route, streaming mode, the + * Vercel UI Message Stream frames. Mirrors `ai.chat` / `ai.chatStream` + * rather than introducing a third shape for the same endpoint. + */ + chatStream: async (agentName: string, request: AiAgentChatRequest): Promise> => { + const route = this.getRoute('ai'); + const res = await this.fetch(`${this.baseUrl}${route}/agents/${encodeURIComponent(agentName)}/chat`, { + method: 'POST', + headers: { 'Accept': 'text/event-stream' }, + body: JSON.stringify({ ...request, stream: true }), + }); + return parseEventStream(res); + }, + }, + + /** + * The human-in-the-loop approval queue. + * + * When a tool call needs a human decision the turn parks an action here + * instead of executing it. An app embedding the chat has to render and + * resolve that queue; until now it had to hand-build these four URLs. + * + * Reads and decisions are separately permissioned server-side (`ai:read` + * vs `ai:approve`), so a caller that can list the queue may still be + * refused on approve — handle the 403, do not assume one implies the other. + */ + pendingActions: { + /** Queued actions, newest first. */ + list: async (options?: ListAiPendingActionsRequest): Promise => { + const route = this.getRoute('ai'); + const params = new URLSearchParams(); + if (options?.status) params.set('status', options.status); + if (options?.conversationId) params.set('conversationId', options.conversationId); + if (options?.limit !== undefined) params.set('limit', String(options.limit)); + const qs = params.toString(); + const res = await this.fetch(`${this.baseUrl}${route}/pending-actions${qs ? `?${qs}` : ''}`); + const body = await this.unwrapResponse(res); + return body?.items ?? []; + }, + + /** One queued action. 404s when the id is unknown. */ + get: async (id: string): Promise => { + const route = this.getRoute('ai'); + const res = await this.fetch(`${this.baseUrl}${route}/pending-actions/${encodeURIComponent(id)}`); + return this.unwrapResponse(res); + }, + + /** + * Approve AND execute. + * + * CHECK THE RETURNED `status`: a tool that fails after approval comes + * back `{ status: 'failed', error }` with HTTP 200, because the approval + * succeeded even though the execution did not. Treating 2xx as "the write + * happened" reports a failed write as a success. + */ + approve: async (id: string): Promise => { + const route = this.getRoute('ai'); + const res = await this.fetch(`${this.baseUrl}${route}/pending-actions/${encodeURIComponent(id)}/approve`, { + method: 'POST', + body: JSON.stringify({}), + }); + return this.unwrapResponse(res); + }, + + /** Reject with an optional reason. Executes nothing. */ + reject: async (id: string, reason?: string): Promise => { + const route = this.getRoute('ai'); + const res = await this.fetch(`${this.baseUrl}${route}/pending-actions/${encodeURIComponent(id)}/reject`, { + method: 'POST', + body: JSON.stringify(reason === undefined ? {} : { reason }), + }); + return this.unwrapResponse(res); + }, + }, }; /** @@ -4747,6 +4868,14 @@ export type { ListAiConversationsRequest, ListAiConversationsResponse, UpdateAiConversationRequest, + AiAgentSummary, + AiAgentsResponse, + AiAgentChatRequest, + AiPendingAction, + ListAiPendingActionsRequest, + ListAiPendingActionsResponse, + ApproveAiPendingActionResponse, + RejectAiPendingActionResponse, GetLocalesResponse, GetTranslationsResponse, GetFieldLabelsResponse, diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index faa324aeb4..b9fb4bd667 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -2205,6 +2205,14 @@ "./api": [ "AckMessage (type)", "AckMessageSchema (const)", + "AiAgentCapabilities (type)", + "AiAgentCapabilitiesSchema (const)", + "AiAgentChatRequest (type)", + "AiAgentChatRequestSchema (const)", + "AiAgentSummary (type)", + "AiAgentSummarySchema (const)", + "AiAgentsResponse (type)", + "AiAgentsResponseSchema (const)", "AiChatRequest (type)", "AiChatRequestSchema (const)", "AiChatResponse (type)", @@ -2217,6 +2225,10 @@ "AiMessageSchema (const)", "AiModelsResponse (type)", "AiModelsResponseSchema (const)", + "AiPendingAction (type)", + "AiPendingActionSchema (const)", + "AiPendingActionStatus (type)", + "AiPendingActionStatusSchema (const)", "AiStreamChunk (type)", "AiStreamChunkSchema (const)", "AnalyticsEndpoint (type)", @@ -2270,6 +2282,8 @@ "ApiTestingUiType (type)", "AppDefinitionResponse (type)", "AppDefinitionResponseSchema (const)", + "ApproveAiPendingActionResponse (type)", + "ApproveAiPendingActionResponseSchema (const)", "AuthEndpoint (type)", "AuthEndpointAlias (type)", "AuthEndpointAliases (const)", @@ -2661,6 +2675,10 @@ "ListAiConversationsRequestSchema (const)", "ListAiConversationsResponse (type)", "ListAiConversationsResponseSchema (const)", + "ListAiPendingActionsRequest (type)", + "ListAiPendingActionsRequestSchema (const)", + "ListAiPendingActionsResponse (type)", + "ListAiPendingActionsResponseSchema (const)", "ListExportJobsRequest (type)", "ListExportJobsRequestSchema (const)", "ListExportJobsResponse (type)", @@ -2880,6 +2898,8 @@ "RegisterDeviceResponseSchema (const)", "RegisterRequest (type)", "RegisterRequestSchema (const)", + "RejectAiPendingActionResponse (type)", + "RejectAiPendingActionResponseSchema (const)", "RequestValidationConfig (type)", "RequestValidationConfigInput (type)", "RequestValidationConfigSchema (const)", diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json index a62bf88de5..fb6e1778a5 100644 --- a/packages/spec/json-schema.manifest.json +++ b/packages/spec/json-schema.manifest.json @@ -69,12 +69,18 @@ "ai/VectorStore", "ai/VectorStoreProvider", "api/AckMessage", + "api/AiAgentCapabilities", + "api/AiAgentChatRequest", + "api/AiAgentSummary", + "api/AiAgentsResponse", "api/AiChatRequest", "api/AiChatResponse", "api/AiCompleteRequest", "api/AiConversation", "api/AiMessage", "api/AiModelsResponse", + "api/AiPendingAction", + "api/AiPendingActionStatus", "api/AiStreamChunk", "api/AnalyticsEndpoint", "api/AnalyticsMetadataResponse", @@ -101,6 +107,7 @@ "api/ApiTestingUiConfig", "api/ApiTestingUiType", "api/AppDefinitionResponse", + "api/ApproveAiPendingActionResponse", "api/AuthEndpoint", "api/AuthFeaturesConfig", "api/AuthProvider", @@ -293,6 +300,8 @@ "api/InstallPackageResponse", "api/ListAiConversationsRequest", "api/ListAiConversationsResponse", + "api/ListAiPendingActionsRequest", + "api/ListAiPendingActionsResponse", "api/ListExportJobsRequest", "api/ListExportJobsResponse", "api/ListFlowsRequest", @@ -401,6 +410,7 @@ "api/RegisterDeviceRequest", "api/RegisterDeviceResponse", "api/RegisterRequest", + "api/RejectAiPendingActionResponse", "api/RequestValidationConfig", "api/ResolveDependenciesRequest", "api/ResolveDependenciesResponse", diff --git a/packages/spec/src/api/protocol.test.ts b/packages/spec/src/api/protocol.test.ts index 121cd8bb82..5bf6a563f0 100644 --- a/packages/spec/src/api/protocol.test.ts +++ b/packages/spec/src/api/protocol.test.ts @@ -51,6 +51,12 @@ import { MarkNotificationsReadRequestSchema, // AI AiChatRequestSchema, + AiAgentsResponseSchema, + AiAgentChatRequestSchema, + ListAiPendingActionsRequestSchema, + ListAiPendingActionsResponseSchema, + ApproveAiPendingActionResponseSchema, + RejectAiPendingActionResponseSchema, AiChatResponseSchema, AiCompleteRequestSchema, AiModelsResponseSchema, @@ -325,6 +331,54 @@ describe('ObjectStack Protocol', () => { UpdateAiConversationRequestSchema.safeParse({}).success, 'PATCH with neither title nor metadata is a 400 on the wire', ).toBe(false); + + // agents — the named-agent surface `objectui` hand-built URLs for (#3718) + expect(AiAgentsResponseSchema.safeParse({ + agents: [{ + name: 'build', label: 'Builder', role: 'authoring', + capabilities: { authoring: true, canvas: true, debug: true, resume: true }, + }], + }).success).toBe(true); + expect( + AiAgentsResponseSchema.safeParse({ agents: [] }).success, + 'an access-filtered catalog is legitimately empty for a seat-less caller', + ).toBe(true); + expect( + AiAgentsResponseSchema.safeParse({ agents: [{ name: 'build', label: 'Builder', role: 'authoring' }] }).success, + 'capabilities drive UI affordances — a row without them is not a row this route returns', + ).toBe(false); + expect(AiAgentChatRequestSchema.safeParse({ + messages: [{ role: 'user', content: 'add a status field' }], + context: { appId: 'crm', objectName: 'account' }, + }).success).toBe(true); + expect( + AiAgentChatRequestSchema.safeParse({ messages: [] }).success, + 'the agent chat route 400s an empty message list, same as /ai/chat', + ).toBe(false); + + // pending actions — the HITL queue + expect(ListAiPendingActionsResponseSchema.safeParse({ + items: [{ + id: 'pa_1', object_name: 'account', action_name: 'update', tool_name: 'record_update', + tool_input: '{"id":"a_1"}', status: 'pending', proposed_at: '2026-07-27T10:00:00Z', + }], + total: 1, + }).success).toBe(true); + expect( + ListAiPendingActionsRequestSchema.safeParse({ status: 'archived' }).success, + 'status is the persisted lifecycle enum, not free text', + ).toBe(false); + expect(ListAiPendingActionsRequestSchema.safeParse({ status: 'pending', limit: 20 }).success).toBe(true); + // Approval and execution are separate outcomes on one 200 response: a tool + // that fails after approval is `status: 'failed'`, NOT an HTTP error. A + // caller reading only `res.ok` reports a failed write as a success. + expect(ApproveAiPendingActionResponseSchema.safeParse({ status: 'executed', result: { id: 'a_1' } }).success).toBe(true); + expect(ApproveAiPendingActionResponseSchema.safeParse({ status: 'failed', error: 'row locked' }).success).toBe(true); + expect( + ApproveAiPendingActionResponseSchema.safeParse({ status: 'rejected' }).success, + 'approve never yields "rejected" — that is the other route', + ).toBe(false); + expect(RejectAiPendingActionResponseSchema.safeParse({ status: 'rejected', id: 'pa_1' }).success).toBe(true); }); it('validates i18n operations', () => { diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index c906d534af..5a4df68a2e 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -1036,6 +1036,122 @@ export const UpdateAiConversationRequestSchema = lazySchema(() => z.object({ message: 'at least one of title or metadata is required', })); +// ── Named agents ────────────────────────────────────────────────────────── +// +// `/ai/agents` and `/ai/agents/:agentName/chat` were mounted long before the +// SDK could express them: `objectui` hand-built both URLs in five places +// because there was nothing to call. Typed from what the routes RETURN, not +// from what a client might like them to (#3718). + +/** What an agent can do, derived server-side from its surface. */ +export const AiAgentCapabilitiesSchema = lazySchema(() => z.object({ + authoring: z.boolean().describe('Authors app metadata (objects/views/flows)'), + canvas: z.boolean().describe('Drives the Live Canvas split view (ADR-0037)'), + debug: z.boolean().describe('Exposes the build-doctor debug drawer'), + resume: z.boolean().describe('Turns resume durable multi-step runs (ADR-0013)'), +})); + +/** One row of the `GET /api/v1/ai/agents` catalog. */ +export const AiAgentSummarySchema = lazySchema(() => z.object({ + name: z.string().describe('Agent name — the `:agentName` path segment'), + label: z.string().describe('Display label'), + role: z.string().describe('Agent role'), + capabilities: AiAgentCapabilitiesSchema.describe('Capability set implied by the agent surface'), +})); + +/** + * `GET /api/v1/ai/agents`. + * + * ACCESS-AWARE: the route returns only agents the CALLER may chat with + * (ADR-0049), so an empty list is a legitimate answer for a seat-less user — + * not an error, and not something to retry. + */ +export const AiAgentsResponseSchema = lazySchema(() => z.object({ + agents: z.array(AiAgentSummarySchema).describe('Agents this caller may chat with'), +})); + +/** + * `POST /api/v1/ai/agents/:agentName/chat`. + * + * Same dual-mode `stream` flag as `/ai/chat`: absent or `true` streams Vercel + * Data Stream frames, `false` returns the JSON reply. The client sets it per + * method rather than leaving it to the caller, so `agents.chat` cannot + * accidentally return a stream body. + */ +export const AiAgentChatRequestSchema = lazySchema(() => z.object({ + messages: z.array(AiMessageSchema).min(1).describe('Conversation messages (at least one)'), + context: z.record(z.string(), z.unknown()).optional().describe('Agent context (app, object, record, …)'), + options: z.record(z.string(), z.unknown()).optional().describe('Request options (model, temperature, …)'), +})); + +// ── HITL pending actions ────────────────────────────────────────────────── +// +// The approval queue an embedding app has to render. `objectui` ships a whole +// approval UI on these four routes over hand-built URLs. + +/** Lifecycle of a proposed action. Mirrors `PendingActionStatus` in contracts. */ +export const AiPendingActionStatusSchema = lazySchema(() => + z.enum(['pending', 'approved', 'executed', 'failed', 'rejected'])); + +/** + * One queued action, as the routes return it — the persisted row, snake_case + * because that is the wire shape, not a style choice. + */ +export const AiPendingActionSchema = lazySchema(() => z.object({ + id: z.string().describe('Pending action id'), + object_name: z.string().describe('Object the action targets'), + action_name: z.string().describe('Action name'), + tool_name: z.string().describe('Tool that would execute it'), + tool_input: z.string().describe('Serialized tool input'), + status: AiPendingActionStatusSchema.describe('Lifecycle status'), + result: z.string().optional().describe('Serialized result, once executed'), + error: z.string().optional().describe('Failure message, when status is failed'), + rejection_reason: z.string().optional().describe('Reason given at rejection'), + conversation_id: z.string().optional().describe('Conversation that proposed it'), + message_id: z.string().optional().describe('Message that proposed it'), + proposed_by: z.string().optional().describe('Actor that proposed it'), + decided_by: z.string().optional().describe('Actor that approved or rejected it'), + proposed_at: z.string().describe('Proposal timestamp (ISO 8601)'), + decided_at: z.string().optional().describe('Decision timestamp (ISO 8601)'), +})); + +/** + * `GET /api/v1/ai/pending-actions` query. + * + * Only the three the ROUTE reads. `AIService.listPendingActions` also accepts + * `objectName`, but the route never forwards it, so declaring it here would + * type a filter that silently does nothing. + */ +export const ListAiPendingActionsRequestSchema = lazySchema(() => z.object({ + status: AiPendingActionStatusSchema.optional().describe('Filter by status'), + conversationId: z.string().optional().describe('Filter by proposing conversation'), + limit: z.number().int().positive().optional().describe('Max rows (server default 100)'), +})); + +export const ListAiPendingActionsResponseSchema = lazySchema(() => z.object({ + items: z.array(AiPendingActionSchema).describe('Queued actions, newest first'), + total: z.number().describe('Number of rows returned (not a total across pages)'), +})); + +/** + * `POST /api/v1/ai/pending-actions/:id/approve` — approve AND execute. + * + * `status: 'failed'` is a 200 with a reason, not an HTTP error: the approval + * succeeded, the execution did not. A caller that only checks `res.ok` will + * report a failed write as a success. + */ +export const ApproveAiPendingActionResponseSchema = lazySchema(() => z.object({ + status: z.enum(['executed', 'failed']).describe('Outcome of executing the approved action'), + result: z.unknown().optional().describe('Tool result, when executed'), + error: z.string().optional().describe('Failure reason, when failed'), +})); + +/** `POST /api/v1/ai/pending-actions/:id/reject` — never executes anything. */ +export const RejectAiPendingActionResponseSchema = lazySchema(() => z.object({ + status: z.literal('rejected').describe('Always "rejected"'), + id: z.string().describe('The rejected action id'), +})); + // ========================================== // i18n Operations // ========================================== @@ -1224,6 +1340,16 @@ export type CreateAiConversationRequest = z.input; export type ListAiConversationsResponse = z.infer; export type UpdateAiConversationRequest = z.input; +export type AiAgentCapabilities = z.infer; +export type AiAgentSummary = z.infer; +export type AiAgentsResponse = z.infer; +export type AiAgentChatRequest = z.input; +export type AiPendingActionStatus = z.infer; +export type AiPendingAction = z.infer; +export type ListAiPendingActionsRequest = z.input; +export type ListAiPendingActionsResponse = z.infer; +export type ApproveAiPendingActionResponse = z.infer; +export type RejectAiPendingActionResponse = z.infer; // i18n Types export type GetLocalesRequest = z.input; From f8b70210e2837d3febda436e5bf79ae26bbff725 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 14:34:10 +0000 Subject: [PATCH 2/2] docs(spec,client): regenerate the protocol reference and document the new ai methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:docs` regenerates `content/docs/references` from the schemas and fails when the committed copy drifts. The seven new AI schemas produce new reference entries, so that check went red on the first push — the artifact half of the same commit, not a separate defect. Also updates the hand-written SDK doc, which enumerates the whole `client.ai.*` surface and carried the #3718 history note. Leaving it at ten methods while shipping seventeen would be the exact drift this line of work keeps closing — and the docs-drift check flagged `content/docs/api/client-sdk.mdx` as affected, which on inspection it genuinely was (the other 107 files it lists are package-level fan-out, unrelated to this diff). The added block documents the two things a caller gets wrong by default: an access-filtered agent catalog is legitimately EMPTY for a seat-less user, and `pendingActions.approve` returns `{ status: 'failed' }` on HTTP 200 when the tool fails after approval — reading only `res.ok` calls that a success. check:docs / check:api-surface / check:spec-changes / check:upgrade-guide all pass; 250 generated files in sync. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WJX6GnuNix7HisBc92THMN --- content/docs/api/client-sdk.mdx | 22 ++++ content/docs/references/api/protocol.mdx | 146 ++++++++++++++++++++++- 2 files changed, 165 insertions(+), 3 deletions(-) diff --git a/content/docs/api/client-sdk.mdx b/content/docs/api/client-sdk.mdx index 72b75011ee..57e0e5aecc 100644 --- a/content/docs/api/client-sdk.mdx +++ b/content/docs/api/client-sdk.mdx @@ -337,6 +337,28 @@ await client.ai.conversations.addMessage(conv.id, { role: 'user', content: 'hi' await client.ai.conversations.update(conv.id, { title: 'Renamed' }); await client.ai.conversations.delete(conv.id); +// Named agents — `ai.chat` talks to the environment default; these talk to one +// you name. The catalog is access-filtered server-side (ADR-0049), so an empty +// list is a legitimate answer for a seat-less user, not an error to retry. +const agents = await client.ai.agents.list(); +await client.ai.agents.chat('build', { messages, context: { appId: 'crm' } }); +for await (const frame of await client.ai.agents.chatStream('build', { messages })) { + if (frame.type === 'text-delta') process.stdout.write(frame.delta as string); +} + +// Pending actions — the human-in-the-loop queue. A tool call needing a human +// decision parks here instead of executing. +await client.ai.pendingActions.list({ status: 'pending', limit: 20 }); +await client.ai.pendingActions.get('pa_1'); +const outcome = await client.ai.pendingActions.approve('pa_1'); +// CHECK `outcome.status`: approve runs the tool, and a tool that fails comes +// back { status: 'failed', error } on HTTP 200 — the approval succeeded, the +// execution did not. Code that reads only `res.ok` calls that a success. +if (outcome.status === 'failed') console.error(outcome.error); +await client.ai.pendingActions.reject('pa_1', 'wrong account'); +// Listing takes `ai:read`, deciding takes `ai:approve` — a caller that can see +// the queue may still be refused on approve. Handle the 403. + // In a React chat UI prefer `useChat()` (`@ai-sdk/react`) over `ai.chatStream`: // it speaks the same protocol and owns message state. These methods are for // everything that is not a component — server code, jobs, CLIs, tests. diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 185da3c835..d3d93baf4f 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -20,13 +20,65 @@ validation. Each entry is a canonical `ActionDescriptorSchema`. ## TypeScript Usage ```typescript -import { AiChatRequest, AiChatResponse, AiCompleteRequest, AiConversation, AiMessage, AiModelsResponse, AiStreamChunk, AutomationActionsResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CreateAiConversationRequest, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, CreateViewRequest, CreateViewResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteMetaItemRequest, DeleteMetaItemResponse, DeleteViewRequest, DeleteViewResponse, FindDataRequest, FindDataResponse, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPresenceRequest, GetPresenceResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetUiViewResponse, GetViewRequest, GetViewResponse, GetWorkflowConfigRequest, GetWorkflowConfigResponse, GetWorkflowStateRequest, GetWorkflowStateResponse, HttpFindQueryParams, ListAiConversationsRequest, ListAiConversationsResponse, ListNotificationsRequest, ListNotificationsResponse, ListViewsRequest, ListViewsResponse, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, NotificationPreferences, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, SaveMetaItemRequest, SaveMetaItemResponse, SetPresenceRequest, SetPresenceResponse, UnregisterDeviceRequest, UnregisterDeviceResponse, UpdateAiConversationRequest, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, UpdateViewRequest, UpdateViewResponse, WorkflowState, WorkflowTransitionRequest, WorkflowTransitionResponse } from '@objectstack/spec/api'; -import type { AiChatRequest, AiChatResponse, AiCompleteRequest, AiConversation, AiMessage, AiModelsResponse, AiStreamChunk, AutomationActionsResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CreateAiConversationRequest, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, CreateViewRequest, CreateViewResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteMetaItemRequest, DeleteMetaItemResponse, DeleteViewRequest, DeleteViewResponse, FindDataRequest, FindDataResponse, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPresenceRequest, GetPresenceResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetUiViewResponse, GetViewRequest, GetViewResponse, GetWorkflowConfigRequest, GetWorkflowConfigResponse, GetWorkflowStateRequest, GetWorkflowStateResponse, HttpFindQueryParams, ListAiConversationsRequest, ListAiConversationsResponse, ListNotificationsRequest, ListNotificationsResponse, ListViewsRequest, ListViewsResponse, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, NotificationPreferences, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, SaveMetaItemRequest, SaveMetaItemResponse, SetPresenceRequest, SetPresenceResponse, UnregisterDeviceRequest, UnregisterDeviceResponse, UpdateAiConversationRequest, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, UpdateViewRequest, UpdateViewResponse, WorkflowState, WorkflowTransitionRequest, WorkflowTransitionResponse } from '@objectstack/spec/api'; +import { AiAgentCapabilities, AiAgentChatRequest, AiAgentSummary, AiAgentsResponse, AiChatRequest, AiChatResponse, AiCompleteRequest, AiConversation, AiMessage, AiModelsResponse, AiPendingAction, AiPendingActionStatus, AiStreamChunk, ApproveAiPendingActionResponse, AutomationActionsResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CreateAiConversationRequest, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, CreateViewRequest, CreateViewResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteMetaItemRequest, DeleteMetaItemResponse, DeleteViewRequest, DeleteViewResponse, FindDataRequest, FindDataResponse, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPresenceRequest, GetPresenceResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetUiViewResponse, GetViewRequest, GetViewResponse, GetWorkflowConfigRequest, GetWorkflowConfigResponse, GetWorkflowStateRequest, GetWorkflowStateResponse, HttpFindQueryParams, ListAiConversationsRequest, ListAiConversationsResponse, ListAiPendingActionsRequest, ListAiPendingActionsResponse, ListNotificationsRequest, ListNotificationsResponse, ListViewsRequest, ListViewsResponse, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, NotificationPreferences, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, RejectAiPendingActionResponse, SaveMetaItemRequest, SaveMetaItemResponse, SetPresenceRequest, SetPresenceResponse, UnregisterDeviceRequest, UnregisterDeviceResponse, UpdateAiConversationRequest, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, UpdateViewRequest, UpdateViewResponse, WorkflowState, WorkflowTransitionRequest, WorkflowTransitionResponse } from '@objectstack/spec/api'; +import type { AiAgentCapabilities, AiAgentChatRequest, AiAgentSummary, AiAgentsResponse, AiChatRequest, AiChatResponse, AiCompleteRequest, AiConversation, AiMessage, AiModelsResponse, AiPendingAction, AiPendingActionStatus, AiStreamChunk, ApproveAiPendingActionResponse, AutomationActionsResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CreateAiConversationRequest, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, CreateViewRequest, CreateViewResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteMetaItemRequest, DeleteMetaItemResponse, DeleteViewRequest, DeleteViewResponse, FindDataRequest, FindDataResponse, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPresenceRequest, GetPresenceResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetUiViewResponse, GetViewRequest, GetViewResponse, GetWorkflowConfigRequest, GetWorkflowConfigResponse, GetWorkflowStateRequest, GetWorkflowStateResponse, HttpFindQueryParams, ListAiConversationsRequest, ListAiConversationsResponse, ListAiPendingActionsRequest, ListAiPendingActionsResponse, ListNotificationsRequest, ListNotificationsResponse, ListViewsRequest, ListViewsResponse, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, NotificationPreferences, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, RejectAiPendingActionResponse, SaveMetaItemRequest, SaveMetaItemResponse, SetPresenceRequest, SetPresenceResponse, UnregisterDeviceRequest, UnregisterDeviceResponse, UpdateAiConversationRequest, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, UpdateViewRequest, UpdateViewResponse, WorkflowState, WorkflowTransitionRequest, WorkflowTransitionResponse } from '@objectstack/spec/api'; // Validate data -const result = AiChatRequest.parse(data); +const result = AiAgentCapabilities.parse(data); ``` +--- + +## AiAgentCapabilities + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **authoring** | `boolean` | ✅ | Authors app metadata (objects/views/flows) | +| **canvas** | `boolean` | ✅ | Drives the Live Canvas split view (ADR-0037) | +| **debug** | `boolean` | ✅ | Exposes the build-doctor debug drawer | +| **resume** | `boolean` | ✅ | Turns resume durable multi-step runs (ADR-0013) | + + +--- + +## AiAgentChatRequest + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **messages** | `Record[]` | ✅ | Conversation messages (at least one) | +| **context** | `Record` | optional | Agent context (app, object, record, …) | +| **options** | `Record` | optional | Request options (model, temperature, …) | + + +--- + +## AiAgentSummary + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **name** | `string` | ✅ | Agent name — the `:agentName` path segment | +| **label** | `string` | ✅ | Display label | +| **role** | `string` | ✅ | Agent role | +| **capabilities** | `{ authoring: boolean; canvas: boolean; debug: boolean; resume: boolean }` | ✅ | Capability set implied by the agent surface | + + +--- + +## AiAgentsResponse + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **agents** | `{ name: string; label: string; role: string; capabilities: object }[]` | ✅ | Agents this caller may chat with | + + --- ## AiChatRequest @@ -116,6 +168,44 @@ const result = AiChatRequest.parse(data); | **defaultModel** | `string` | optional | Default model id, when the service reports one | +--- + +## AiPendingAction + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **id** | `string` | ✅ | Pending action id | +| **object_name** | `string` | ✅ | Object the action targets | +| **action_name** | `string` | ✅ | Action name | +| **tool_name** | `string` | ✅ | Tool that would execute it | +| **tool_input** | `string` | ✅ | Serialized tool input | +| **status** | `Enum<'pending' \| 'approved' \| 'executed' \| 'failed' \| 'rejected'>` | ✅ | Lifecycle status | +| **result** | `string` | optional | Serialized result, once executed | +| **error** | `string` | optional | Failure message, when status is failed | +| **rejection_reason** | `string` | optional | Reason given at rejection | +| **conversation_id** | `string` | optional | Conversation that proposed it | +| **message_id** | `string` | optional | Message that proposed it | +| **proposed_by** | `string` | optional | Actor that proposed it | +| **decided_by** | `string` | optional | Actor that approved or rejected it | +| **proposed_at** | `string` | ✅ | Proposal timestamp (ISO 8601) | +| **decided_at** | `string` | optional | Decision timestamp (ISO 8601) | + + +--- + +## AiPendingActionStatus + +### Allowed Values + +* `pending` +* `approved` +* `executed` +* `failed` +* `rejected` + + --- ## AiStreamChunk @@ -127,6 +217,19 @@ const result = AiChatRequest.parse(data); | **type** | `string` | ✅ | Frame type (text-delta, tool-input-available, finish, error, …) | +--- + +## ApproveAiPendingActionResponse + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **status** | `Enum<'executed' \| 'failed'>` | ✅ | Outcome of executing the approved action | +| **result** | `any` | optional | Tool result, when executed | +| **error** | `string` | optional | Failure reason, when failed | + + --- ## AutomationActionsResponse @@ -913,6 +1016,31 @@ const result = AiChatRequest.parse(data); | **conversations** | `{ id: string; title?: string; agentId?: string; userId?: string; … }[]` | ✅ | Matching conversations | +--- + +## ListAiPendingActionsRequest + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **status** | `Enum<'pending' \| 'approved' \| 'executed' \| 'failed' \| 'rejected'>` | optional | Filter by status | +| **conversationId** | `string` | optional | Filter by proposing conversation | +| **limit** | `integer` | optional | Max rows (server default 100) | + + +--- + +## ListAiPendingActionsResponse + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **items** | `{ id: string; object_name: string; action_name: string; tool_name: string; … }[]` | ✅ | Queued actions, newest first | +| **total** | `number` | ✅ | Number of rows returned (not a total across pages) | + + --- ## ListNotificationsRequest @@ -1145,6 +1273,18 @@ const result = AiChatRequest.parse(data); | **success** | `boolean` | ✅ | Whether registration succeeded | +--- + +## RejectAiPendingActionResponse + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **status** | `'rejected'` | ✅ | Always "rejected" | +| **id** | `string` | ✅ | The rejected action id | + + --- ## SaveMetaItemRequest