diff --git a/.changeset/ai-namespace-expresses-real-surface.md b/.changeset/ai-namespace-expresses-real-surface.md
new file mode 100644
index 0000000000..e2530f5d65
--- /dev/null
+++ b/.changeset/ai-namespace-expresses-real-surface.md
@@ -0,0 +1,65 @@
+---
+"@objectstack/client": major
+"@objectstack/spec": major
+---
+
+feat(client,spec)!: the SDK's `ai` namespace now expresses the AI surface that exists (#3718)
+
+`client.ai` and the AI service were **disjoint sets**. The namespace held three
+methods — `nlq`, `suggest`, `insights` — whose URLs no repo has ever mounted
+(removed in v17), while `service-ai` mounted 12 routes the SDK could not reach
+at all. v17 closed the first half by deleting the dead methods. This closes the
+second: the SDK now reaches every route that is meant to be tenant API surface.
+
+| SDK | Route |
+|---|---|
+| `ai.chat(request)` | `POST /api/v1/ai/chat` — forces `stream: false`, so the JSON mode is what you get |
+| `ai.chatStream(request)` | `POST /api/v1/ai/chat` — `AsyncIterable` of UI Message Stream frames |
+| `ai.complete(request)` | `POST /api/v1/ai/complete` |
+| `ai.models()` | `GET /api/v1/ai/models` — the ADR-0028 plan-filtered picker list |
+| `ai.conversations.create/list/get/update/delete/addMessage` | the six `/api/v1/ai/conversations` routes |
+
+`ai.chatStream` returns a promise for an async iterable rather than being an
+async generator, so the request is issued — and an HTTP error thrown — when you
+call it, not when you first iterate.
+
+**Where the server is.** `service-ai` is a Cloud/EE package in the `cloud`
+repo; this repo only proxies `/api/v1/ai/**` and 404s `AI service is not
+configured` without it. Check `discovery.services` before calling, exactly as
+for any other plugin-provided namespace. For a React chat UI, `useChat()`
+(`@ai-sdk/react`) is still the better client — it speaks the same protocol
+`ai.chatStream` parses and owns message state; these methods are for callers
+that are not components.
+
+**Breaking — the spec's dead AI declarations are retired.** All three had no
+implementation anywhere and no runtime consumer:
+
+- `Ai{Nlq,Suggest,Insights}{Request,Response}[Schema]` → replaced by the wire
+ shapes of the real routes: `AiChat{Request,Response}`, `AiStreamChunk`,
+ `AiCompleteRequest`, `AiModelsResponse`, `AiConversation`, `AiMessage`,
+ `{Create,List,Update}AiConversation*`. The six retired JSON Schemas are
+ dropped from `json-schema.manifest.json` (deliberate retirement, #2978).
+- `DEFAULT_AI_ROUTES` → deleted, and `getDefaultRouteRegistrations()` returns 8
+ groups instead of 9. It declared the three phantom endpoints and had no
+ runtime consumer; re-declaring the real ones here would recreate the same
+ illusion, since they are mounted from another repo.
+- `AiProtocol` (`aiNlq?` / `aiSuggest?` / `aiInsights?`) → deleted. Nothing
+ implemented it and nothing dispatched through it. The real server contract is
+ `IAIService` + `IAIConversationService` in `@objectstack/spec/contracts`.
+
+**The guard.** `/api/v1/ai/` becomes a bounded prefix exemption in the capstone
+(#3642) alongside the control plane — bounded from both ends: only `ai.*` may
+use it, and the namespace must still be reaching it. That is not a
+wave-through. The reachability check lives where the routes are:
+`cloud`'s `packages/service-ai/src/ai-route-ledger.conformance.test.ts` reads
+the table `buildAIRoutes()` returns and drives this SDK against it, so an
+`ai.*` URL that stops resolving fails a test in the repo that mounts it. The
+wildcard-only bound stays **0** — these URLs never touch the `* /ai/**` row,
+which is what certified three dead methods for years.
+
+The four replaced client tests are worth naming: they mocked `fetch` and
+asserted the URL the client *built*, never that anything answered it, and
+passed for years against endpoints that did not exist. The new ones assert only
+what this repo can honestly know — verb, path, and the body decisions the SDK
+makes for you (`stream: false` on `chat`, the 204 on `delete`, SSE frame
+parsing) — and leave "does it resolve" to the ledger next to the routes.
diff --git a/content/docs/api/client-sdk.mdx b/content/docs/api/client-sdk.mdx
index 615b6eac8d..72b75011ee 100644
--- a/content/docs/api/client-sdk.mdx
+++ b/content/docs/api/client-sdk.mdx
@@ -128,7 +128,7 @@ The `@objectstack/client` SDK aims to implement the ObjectStack API protocol spe
| **storage** | ✅ | 2 | File upload & download |
| **i18n** | ✅ | 3 | Internationalization |
| **notifications** | ✅ | 3 | List, mark-read, mark-all-read (inbox/receipt spine, ADR-0030) |
-| **ai** | ✅ | 3 | AI services (NLQ, suggest, insights) |
+| **ai** | ✅ | 10 | Chat (JSON + streaming), completion, model picker, conversation CRUD — the surface `service-ai` (Cloud/EE) mounts |
The former `permissions`, `views`, `workflow`, and `realtime` namespaces (and
the notifications device/preference helpers) were removed in #3612: no server
@@ -312,21 +312,40 @@ await client.notifications.markAllRead();
// through sys_inbox_message and tracks read-state in sys_notification_receipt.
// These helpers will be repointed during the objectui bell cut-over.
-// AI — the `client.ai` namespace was REMOVED in v17 (#3718).
-//
-// It held `nlq`, `suggest` and `insights`, which built
-// /api/v1/ai/{nlq,suggest,insights}. No server in any repo ever mounted those
-// paths, so every call 404ed for the whole life of the namespace. They were
-// typed and shipped, which is exactly why they looked usable.
-//
-// The AI surface that DOES exist is served by `service-ai` (Cloud/EE):
-// POST /api/v1/ai/chat and /chat/stream, POST /complete, GET /models, and six
-// /conversations routes. The SDK has no method for any of them yet — that is
-// tracked on #3718 as new API, not as a rename of what was removed.
+// AI — served by `service-ai` (Cloud/EE); 404s "AI service is not configured"
+// when the plugin is absent, so check `discovery.services` first.
+const answer = await client.ai.chat({
+ messages: [{ role: 'user', content: 'How many open orders this quarter?' }],
+ conversationId, // omit to have one created and echoed back
+});
+answer.content; // string
+answer.usage?.totalTokens;
+
+// Streaming — the Vercel UI Message Stream Protocol, frame by frame.
+for await (const frame of await client.ai.chatStream({ messages })) {
+ if (frame.type === 'text-delta') process.stdout.write(frame.delta as string);
+}
+
+await client.ai.complete({ prompt: 'Summarise this account in one line:' });
+await client.ai.models(); // plan-filtered picker list (ADR-0028)
+
+// Conversations — all six routes, scoped to the authenticated user server-side.
+const conv = await client.ai.conversations.create({ title: 'Q3 pipeline' });
+await client.ai.conversations.list({ limit: 20 });
+await client.ai.conversations.get(conv.id);
+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);
+
+// 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.
//
-// For chat, call the endpoint directly with the Vercel AI SDK
-// (`useChat()` from `@ai-sdk/react`); it speaks the Data Stream Protocol that
-// POST /api/v1/ai/chat serves.
+// #3718 history: `client.ai` used to hold `nlq`, `suggest` and `insights`,
+// building /api/v1/ai/{nlq,suggest,insights}. No server in any repo ever
+// mounted those paths, so every call 404ed for the whole life of the
+// namespace. They were typed and shipped, which is exactly why they looked
+// usable. v17 removed them; the methods above are the surface that exists.
// i18n — Internationalization
await client.i18n.getLocales();
diff --git a/content/docs/api/plugin-endpoints.mdx b/content/docs/api/plugin-endpoints.mdx
index a57315e72f..6ff76350aa 100644
--- a/content/docs/api/plugin-endpoints.mdx
+++ b/content/docs/api/plugin-endpoints.mdx
@@ -95,19 +95,19 @@ The core dispatcher implements only the list / read / read-all routes above. Dev
### AI (`/ai`) — Plugin Required
-These are the routes `service-ai` mounts:
-
-| Method | Endpoint | Description |
-|:-------|:---------|:------------|
-| POST | `/ai/chat` | Chat completion (Vercel Data Stream or JSON) |
-| POST | `/ai/chat/stream` | SSE streaming chat |
-| POST | `/ai/complete` | Text completion |
-| GET | `/ai/models` | Models this environment offers (ADR-0028) |
-| GET | `/ai/status` | Active adapter provenance |
-| GET | `/ai/effective-model` | Resolved model ids and their source |
-| POST / GET | `/ai/conversations` | Create / list conversations |
-| GET / PATCH / DELETE | `/ai/conversations/:id` | Read / update / delete |
-| POST | `/ai/conversations/:id/messages` | Append a message |
+These are the routes `service-ai` mounts, and the SDK method that reaches each:
+
+| Method | Endpoint | SDK | Description |
+|:-------|:---------|:----|:------------|
+| POST | `/ai/chat` | `ai.chat` / `ai.chatStream` | Chat completion — JSON with `stream: false`, otherwise the Vercel UI Message Stream |
+| POST | `/ai/chat/stream` | — | Generic-SSE twin of `/ai/chat`: the same completion without the tool loop or persistence |
+| POST | `/ai/complete` | `ai.complete` | Text completion |
+| GET | `/ai/models` | `ai.models` | Models this environment offers (ADR-0028) |
+| GET | `/ai/status` | — | Active adapter provenance (console diagnostics) |
+| GET | `/ai/effective-model` | — | Resolved model ids and their source (console diagnostics) |
+| POST / GET | `/ai/conversations` | `ai.conversations.create` / `.list` | Create / list conversations |
+| GET / PATCH / DELETE | `/ai/conversations/:id` | `ai.conversations.get` / `.update` / `.delete` | Read / update / delete |
+| POST | `/ai/conversations/:id/messages` | `ai.conversations.addMessage` | Append a message |
**This table used to be inverted (#3718).** It listed `/ai/nlq`,
@@ -116,12 +116,16 @@ and its callout stated "there is no `/ai/chat` route", which was wrong.
The three phantom routes were declared in `DEFAULT_AI_ROUTES` and called by
`client.ai.nlq/suggest/insights`; every call 404ed. **v17 removed that SDK
-namespace** rather than build endpoints for it, so nothing calls them now.
-
-No route above has a client-SDK method yet — reach them directly, or with
-`useChat()` (`@ai-sdk/react`) for chat, which speaks the Data Stream Protocol
-`POST /ai/chat` serves. Reviewed dispositions for all 12 live in the `cloud`
-repo, `packages/service-ai/src/ai-route-ledger.ts`.
+namespace** rather than build endpoints for it, and the same issue then gave
+the SDK the surface that does exist — the `ai.*` column above.
+
+Reviewed dispositions for all 12 routes live in the `cloud` repo,
+`packages/service-ai/src/ai-route-ledger.ts`, whose conformance test reads
+`buildAIRoutes()` and drives the SDK against it. The three rows with no SDK
+method are deliberate: `/status` and `/effective-model` are operator
+diagnostics, and `/chat/stream` is superseded by `/chat`'s streaming mode.
+For a React chat UI prefer `useChat()` (`@ai-sdk/react`) — it speaks the same
+protocol `ai.chatStream` parses and owns message state for you.
### i18n (`/i18n`) — Plugin Required
diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx
index a8953e7331..840b1295a6 100644
--- a/content/docs/references/api/protocol.mdx
+++ b/content/docs/references/api/protocol.mdx
@@ -20,87 +20,111 @@ validation. Each entry is a canonical `ActionDescriptorSchema`.
## TypeScript Usage
```typescript
-import { AiInsightsRequest, AiInsightsResponse, AiNlqRequest, AiNlqResponse, AiSuggestRequest, AiSuggestResponse, AutomationActionsResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, 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, 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, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, UpdateViewRequest, UpdateViewResponse, WorkflowState, WorkflowTransitionRequest, WorkflowTransitionResponse } from '@objectstack/spec/api';
-import type { AiInsightsRequest, AiInsightsResponse, AiNlqRequest, AiNlqResponse, AiSuggestRequest, AiSuggestResponse, AutomationActionsResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, 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, 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, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, UpdateViewRequest, UpdateViewResponse, WorkflowState, WorkflowTransitionRequest, WorkflowTransitionResponse } from '@objectstack/spec/api';
+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';
// Validate data
-const result = AiInsightsRequest.parse(data);
+const result = AiChatRequest.parse(data);
```
---
-## AiInsightsRequest
+## AiChatRequest
### Properties
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
-| **object** | `string` | ✅ | Object name to analyze |
-| **recordId** | `string` | optional | Specific record to analyze |
-| **type** | `Enum<'summary' \| 'trends' \| 'anomalies' \| 'recommendations'>` | optional | Type of insight |
+| **messages** | `Record[]` | ✅ | Conversation messages (at least one) |
+| **system** | `string` | optional | System prompt, prepended as a system message |
+| **model** | `string` | optional | Model id override |
+| **temperature** | `number` | optional | Sampling temperature |
+| **maxTokens** | `integer` | optional | Maximum tokens to generate |
+| **stream** | `boolean` | optional | false → JSON response; otherwise the UI Message Stream Protocol |
+| **conversationId** | `string` | optional | Conversation to persist this turn into (auto-created when omitted) |
+| **turnId** | `string` | optional | Stable per-turn idempotency key (ADR-0013 D1) |
+| **options** | `Record` | optional | Legacy nested request options |
---
-## AiInsightsResponse
+## AiChatResponse
### Properties
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
-| **insights** | `{ type: string; title: string; description: string; confidence?: number; … }[]` | ✅ | Generated insights |
+| **content** | `string` | ✅ | Generated text |
+| **model** | `string` | optional | Model that produced it |
+| **toolCalls** | `any[]` | optional | Tool calls the model requested (Vercel `ToolCallPart`) |
+| **usage** | `{ promptTokens: number; completionTokens: number; totalTokens: number }` | optional | Token usage |
+| **conversationId** | `string` | optional | Conversation the turn was persisted into |
---
-## AiNlqRequest
+## AiCompleteRequest
### Properties
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
-| **query** | `string` | ✅ | Natural language query string |
-| **object** | `string` | optional | Target object context |
-| **conversationId** | `string` | optional | Conversation ID for multi-turn queries |
+| **prompt** | `string` | ✅ | Prompt text |
+| **options** | `Record` | optional | Request options (model, temperature, maxTokens, …) |
---
-## AiNlqResponse
+## AiConversation
### Properties
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
-| **query** | `any` | ✅ | Generated structured query (AST) |
-| **explanation** | `string` | optional | Human-readable explanation of the query |
-| **confidence** | `number` | optional | Confidence score (0-1) |
-| **suggestions** | `string[]` | optional | Suggested follow-up queries |
+| **id** | `string` | ✅ | Conversation id |
+| **title** | `string` | optional | Title / summary |
+| **agentId** | `string` | optional | Agent this conversation is bound to |
+| **userId** | `string` | optional | Owning user |
+| **messages** | `Record[]` | ✅ | Message history |
+| **createdAt** | `string` | ✅ | Creation timestamp (ISO 8601) |
+| **updatedAt** | `string` | ✅ | Last update timestamp (ISO 8601) |
+| **metadata** | `Record` | optional | Conversation metadata |
---
-## AiSuggestRequest
+## AiMessage
### Properties
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
-| **object** | `string` | ✅ | Object name for context |
-| **field** | `string` | optional | Field to suggest values for |
-| **recordId** | `string` | optional | Record ID for context |
-| **partial** | `string` | optional | Partial input for completion |
+| **role** | `Enum<'system' \| 'user' \| 'assistant' \| 'tool'>` | ✅ | Message role |
+| **content** | `any` | optional | Message content: a string, or an array of content parts |
+| **parts** | `any[]` | optional | Vercel AI SDK v6 message parts (alternative to `content`) |
---
-## AiSuggestResponse
+## AiModelsResponse
### Properties
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
-| **suggestions** | `{ value: any; label: string; confidence?: number; reason?: string }[]` | ✅ | Suggested values |
+| **models** | `string \| { id: string; label: string; default: boolean }[]` | ✅ | Models this environment offers |
+| **defaultModel** | `string` | optional | Default model id, when the service reports one |
+
+
+---
+
+## AiStreamChunk
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **type** | `string` | ✅ | Frame type (text-delta, tool-input-available, finish, error, …) |
---
@@ -196,6 +220,19 @@ const result = AiInsightsRequest.parse(data);
| **reason** | `string` | optional | Reason if denied |
+---
+
+## CreateAiConversationRequest
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **title** | `string` | optional | Initial title |
+| **agentId** | `string` | optional | Agent to bind the conversation to |
+| **metadata** | `Record` | optional | Conversation metadata |
+
+
---
## CreateDataRequest
@@ -854,6 +891,30 @@ const result = AiInsightsRequest.parse(data);
| **count** | `boolean` | optional | Include total count in response. |
+---
+
+## ListAiConversationsRequest
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **agentId** | `string` | optional | Filter by agent |
+| **limit** | `integer` | optional | Maximum conversations to return |
+| **cursor** | `string` | optional | Pagination cursor |
+
+
+---
+
+## ListAiConversationsResponse
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **conversations** | `{ id: string; title?: string; agentId?: string; userId?: string; … }[]` | ✅ | Matching conversations |
+
+
---
## ListNotificationsRequest
@@ -1156,6 +1217,18 @@ const result = AiInsightsRequest.parse(data);
| **success** | `boolean` | ✅ | Whether unregistration succeeded |
+---
+
+## UpdateAiConversationRequest
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **title** | `string` | optional | New title |
+| **metadata** | `Record` | optional | New metadata |
+
+
---
## UpdateDataRequest
diff --git a/packages/client/src/client-url-conformance.test.ts b/packages/client/src/client-url-conformance.test.ts
index 211e149430..311042e694 100644
--- a/packages/client/src/client-url-conformance.test.ts
+++ b/packages/client/src/client-url-conformance.test.ts
@@ -151,23 +151,34 @@ function matches(verb: string, path: string): Pattern | undefined {
const CONTROL_PLANE = '/api/v1/cloud/';
const CONTROL_PLANE_NAMESPACE = 'projects.';
-/*
- * There is no AI exemption any more, and that is the end state — not an
- * omission.
+/**
+ * The AI plane. `/api/v1/ai/*` is served by `service-ai`, a Cloud/EE package in
+ * the sibling `cloud` repo; this repo's dispatcher only PROXIES the prefix to
+ * whatever `buildAIRoutes()` mounted (or 404s "AI service is not configured").
+ * No in-repo ledger can enumerate that table, so — exactly like the control
+ * plane above — the prefix is exempt HERE and guarded THERE.
+ *
+ * READ THE HISTORY BEFORE WIDENING THIS. `/api/v1/ai/` used to be matched by
+ * the dispatcher's `* /ai/**` wildcard row, and that was worse than no match:
+ * a wildcard claims the FAMILY, so `ai.nlq` / `ai.suggest` / `ai.insights`
+ * 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.
*
- * `/api/v1/ai/` was briefly exempted here the way the control plane still is:
- * `service-ai` is a Cloud/EE package in the `cloud` repo, so no ledger here can
- * enumerate its table. Before that it was a `* /ai/**` WILDCARD match, which
- * was worse — a wildcard claims the family, so all three `ai.*` methods counted
- * as matched when none of their URLs was in the real table at all (#3718).
+ * 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".
*
- * v17 removed the `ai` namespace outright, so no SDK method targets the prefix
- * and there is nothing left to exempt. If an `ai.*` method is ever added back,
- * it will match no route here and fail the `unmatched` assertion below — which
- * is correct: the real AI surface is ledgered in `cloud`
- * (`packages/service-ai/src/ai-route-ledger.ts`), and a new method should be
- * verified against it there rather than waved through by a prefix here.
+ * Bounded from both ends below: only `ai.*` may use it, and the namespace must
+ * still be reaching it.
*/
+const AI_PLANE = '/api/v1/ai/';
+const AI_PLANE_NAMESPACE = 'ai.';
// ---------------------------------------------------------------------------
// 2. The recorder
@@ -347,6 +358,7 @@ describe('client URL conformance ↔ the union of all four route ledgers (#3642)
const silent: string[] = [];
const malformed: string[] = [];
const controlPlane: string[] = [];
+ const aiPlane: string[] = [];
const wildcardOnly: string[] = [];
for (const name of METHODS) {
@@ -368,6 +380,10 @@ describe('client URL conformance ↔ the union of all four route ledgers (#3642)
}
const path = new URL(call.url, BASE).pathname;
if (path.startsWith(CONTROL_PLANE)) { controlPlane.push(`${name} → ${call.verb} ${path}`); continue; }
+ // Checked BEFORE `matches()` on purpose: the dispatcher's `* /ai/**`
+ // row would otherwise absorb these into `wildcardOnly` — the exact
+ // false-positive evidence that hid three dead methods (#3718).
+ if (path.startsWith(AI_PLANE)) { aiPlane.push(`${name} → ${call.verb} ${path}`); continue; }
const hit = matches(call.verb, path);
if (!hit) { unmatched.push(`${name} → ${call.verb} ${path}`); continue; }
if (hit.route.includes('**')) wildcardOnly.push(`${name} → ${call.verb} ${path} (via ${hit.route})`);
@@ -402,6 +418,20 @@ describe('client URL conformance ↔ the union of all four route ledgers (#3642)
).toEqual([]);
expect(controlPlane.length, 'the projects namespace should still be reaching the control plane').toBeGreaterThan(0);
+ // The AI-plane hole, bounded the same way: only `ai.*` may use it. The
+ // count assertion is the half that matters most here — if the namespace
+ // stopped issuing requests, the exemption would silently become a hole
+ // with nothing behind it, which is how the wildcard row went wrong.
+ const aiTrespassers = aiPlane.filter((e) => !e.startsWith(AI_PLANE_NAMESPACE));
+ expect(
+ aiTrespassers,
+ `non-ai methods targeting /api/v1/ai/, which no in-repo ledger can vouch for:\n${aiTrespassers.join('\n')}`,
+ ).toEqual([]);
+ expect(
+ aiPlane.length,
+ 'the ai namespace should be reaching the AI plane — if it stops, drop the exemption',
+ ).toBeGreaterThan(0);
+
// HOW STRONG IS THIS GUARD, HONESTLY. A `**` row asserts only that a prefix
// family is CLAIMED, not that the specific URL resolves. That was this
@@ -412,8 +442,9 @@ describe('client URL conformance ↔ the union of all four route ledgers (#3642)
// THAT family (#3718, in `cloud`, where service-ai lives) showed the
// wildcard had not been weak evidence but WRONG evidence: none of the three
// URLs is in the real table, and nothing in any repo mounts them (#3718).
- // v17 removed that namespace outright, so no SDK method targets `/ai/` and
- // the bound is 0 with nothing exempted to get there.
+ // v17 removed that namespace; #3718 then expressed the real one, which is
+ // reached through the bounded AI-plane exemption above — NOT through the
+ // wildcard row, so the bound below still stands at 0.
//
// ZERO IS THE POINT: every remaining matched call rests on an exact route
// some ledger enumerated. Raising this bound reintroduces the one kind of
diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts
index 1d2a0c6d4f..fb0d45c1e0 100644
--- a/packages/client/src/client.test.ts
+++ b/packages/client/src/client.test.ts
@@ -604,29 +604,131 @@ describe('Notifications namespace', () => {
});
});
-describe('AI namespace (removed in v17 — #3718)', () => {
+describe('AI namespace (#3718)', () => {
/**
+ * READ THIS BEFORE ADDING A TEST HERE.
+ *
* This block used to hold four passing tests for `ai.nlq`, `ai.suggest`
* and `ai.insights`. Every one of them mocked `fetch` and asserted the URL
* the client BUILT — never that anything answered it. All three endpoints
- * were mounted by nothing, in any repo, for the whole life of those tests.
+ * were mounted by nothing, in any repo, for the whole life of those tests
+ * (#3584, #3611, #3636, #3702 are the same shape).
*
- * That is the shape of test this audit family kept finding behind green
- * suites (#3584, #3611, #3636, #3702), so the replacement asserts the one
- * thing that is actually true and worth defending: the namespace is gone
- * and must not come back without a route behind it.
+ * So the assertions below are deliberately the *narrow* half — verb, path,
+ * and the body decisions this SDK makes on the caller's behalf. The claim
+ * that these paths RESOLVE is not made here and cannot be: the AI service
+ * is a Cloud/EE package in the `cloud` repo. It is made where the routes
+ * are, by `packages/service-ai/src/ai-route-ledger.conformance.test.ts`,
+ * which reads `buildAIRoutes()` and drives this very namespace against it.
*/
- it('is gone — no method may return without an endpoint to answer it', () => {
- const { client } = createMockClient({ success: true, data: {} });
- expect((client as unknown as Record).ai).toBeUndefined();
+ it('chat forces stream:false — the endpoint streams by default', async () => {
+ const { client, fetchMock } = createMockClient({ content: 'hello', model: 'gpt-4o-mini' });
+ const result = await client.ai.chat({ messages: [{ role: 'user', content: 'hi' }] });
+ expect(result.content).toBe('hello');
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe('http://localhost:3000/api/v1/ai/chat');
+ expect(opts.method).toBe('POST');
+ // Without this the "JSON" method would come back as an SSE stream and
+ // `res.json()` would throw on the first frame.
+ expect(JSON.parse(opts.body).stream).toBe(false);
+ });
+
+ it('complete posts the prompt', async () => {
+ const { client, fetchMock } = createMockClient({ content: '42' });
+ const result = await client.ai.complete({ prompt: 'answer:' });
+ expect(result.content).toBe('42');
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe('http://localhost:3000/api/v1/ai/complete');
+ expect(JSON.parse(opts.body)).toEqual({ prompt: 'answer:' });
+ });
+
+ it('models reads the picker allowlist', async () => {
+ const { client, fetchMock } = createMockClient({
+ models: [{ id: 'gpt-4o-mini', label: 'GPT-4o mini', default: true }],
+ defaultModel: 'gpt-4o-mini',
+ });
+ const result = await client.ai.models();
+ expect(result.defaultModel).toBe('gpt-4o-mini');
+ expect(fetchMock.mock.calls[0][0]).toBe('http://localhost:3000/api/v1/ai/models');
+ });
+
+ it('conversations CRUD targets the six mounted routes', async () => {
+ const conv = { id: 'c1', messages: [], createdAt: 'now', updatedAt: 'now' };
+
+ const created = createMockClient(conv);
+ await created.client.ai.conversations.create({ title: 'Q3' });
+ expect(created.fetchMock.mock.calls[0][0]).toBe('http://localhost:3000/api/v1/ai/conversations');
+ expect(created.fetchMock.mock.calls[0][1].method).toBe('POST');
+
+ const listed = createMockClient({ conversations: [conv] });
+ const list = await listed.client.ai.conversations.list({ limit: 10 });
+ expect(list).toHaveLength(1);
+ expect(listed.fetchMock.mock.calls[0][0]).toBe('http://localhost:3000/api/v1/ai/conversations?limit=10');
+
+ const got = createMockClient(conv);
+ await got.client.ai.conversations.get('c 1');
+ expect(got.fetchMock.mock.calls[0][0]).toBe('http://localhost:3000/api/v1/ai/conversations/c%201');
+
+ const patched = createMockClient(conv);
+ await patched.client.ai.conversations.update('c1', { title: 'Renamed' });
+ expect(patched.fetchMock.mock.calls[0][1].method).toBe('PATCH');
+
+ const messaged = createMockClient(conv);
+ await messaged.client.ai.conversations.addMessage('c1', { role: 'user', content: 'hi' });
+ expect(messaged.fetchMock.mock.calls[0][0]).toBe('http://localhost:3000/api/v1/ai/conversations/c1/messages');
+ });
+
+ it('conversations.delete reports the 204 the route returns', async () => {
+ // DELETE answers 204 with no body; unwrapping it would throw in json().
+ const { client, fetchMock } = createMockClient(undefined, 204);
+ expect(await client.ai.conversations.delete('c1')).toEqual({ deleted: true });
+ expect(fetchMock.mock.calls[0][1].method).toBe('DELETE');
+ });
+
+ it('chatStream parses the UI Message Stream frames, ignoring [DONE] and `g:` lines', async () => {
+ const sse = [
+ 'data: {"type":"start"}\n\n',
+ 'data: {"type":"text-delta","id":"0","delta":"Hel"}\n\n',
+ 'g:{"text":"thinking"}\n', // legacy Data Stream line, single \n
+ 'data: {"type":"text-delta","id":"0","delta":"lo"}\n\n',
+ 'data: {"type":"finish","finishReason":"stop"}\n\ndata: [DONE]\n\n',
+ ];
+ const encoder = new TextEncoder();
+ let i = 0;
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Headers({ 'content-type': 'text/event-stream' }),
+ // Chunk boundaries deliberately fall mid-frame in the last entry.
+ body: { getReader: () => ({
+ read: async () => (i < sse.length
+ ? { done: false, value: encoder.encode(sse[i++]) }
+ : { done: true, value: undefined }),
+ }) },
+ });
+ const client = new ObjectStackClient({ baseUrl: 'http://localhost:3000', fetch: fetchMock });
+
+ const frames: any[] = [];
+ for await (const frame of await client.ai.chatStream({ messages: [{ role: 'user', content: 'hi' }] })) {
+ frames.push(frame);
+ }
+
+ expect(frames.map((f) => f.type)).toEqual(['start', 'text-delta', 'text-delta', 'finish']);
+ expect(frames.filter((f) => f.type === 'text-delta').map((f) => f.delta).join('')).toBe('Hello');
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe('http://localhost:3000/api/v1/ai/chat');
+ expect(JSON.parse(opts.body).stream).toBe(true);
+ expect(opts.headers.Accept).toBe('text/event-stream');
});
- it('still directs chat at the Vercel AI SDK', () => {
- // Unchanged guidance, and the reason no `chat` method is being added
- // back with the real surface: `useChat()` (`@ai-sdk/react`) speaks the
- // Data Stream Protocol against POST /api/v1/ai/chat directly.
- const { client } = createMockClient({ success: true, data: {} });
- expect((client as unknown as Record).ai).toBeUndefined();
+ it('chatStream fails loudly when the runtime exposes no response body', async () => {
+ // The request still goes out — the failure is on the first read, which
+ // is where a fetch polyfill without `Response.body` reveals itself.
+ const { client, fetchMock } = createMockClient({});
+ const stream = await client.ai.chatStream({ messages: [{ role: 'user', content: 'hi' }] });
+ expect(fetchMock).toHaveBeenCalledOnce();
+ await expect((async () => { for await (const _frame of stream) { /* drain */ } })())
+ .rejects.toThrow(/no body/i);
});
});
diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts
index 6d33585942..84e0bc3b30 100644
--- a/packages/client/src/index.ts
+++ b/packages/client/src/index.ts
@@ -29,10 +29,20 @@ import {
ListNotificationsResponse,
MarkNotificationsReadResponse,
MarkAllNotificationsReadResponse,
- // Ai{Nlq,Suggest,Insights}{Request,Response} are no longer imported: the
- // `ai` namespace that used them is gone in v17 (#3718). They are still
- // RE-EXPORTED below, straight from `@objectstack/spec/api`, so anyone
- // holding those types keeps them while the spec still declares them.
+ // The AI wire types (#3718). `Ai{Nlq,Suggest,Insights}{Request,Response}`
+ // used to be here; they typed three endpoints nothing has ever mounted and
+ // went with the methods that called them. These type the routes that exist.
+ AiMessage,
+ AiChatRequest,
+ AiChatResponse,
+ AiStreamChunk,
+ AiCompleteRequest,
+ AiModelsResponse,
+ AiConversation,
+ CreateAiConversationRequest,
+ ListAiConversationsRequest,
+ ListAiConversationsResponse,
+ UpdateAiConversationRequest,
GetLocalesResponse,
GetTranslationsResponse,
GetFieldLabelsResponse,
@@ -230,6 +240,70 @@ export interface StandardError {
details?: Record;
}
+/**
+ * Parse an SSE response body into the JSON frames it carries.
+ *
+ * Used by `ai.chatStream` (#3718). Both AI streaming routes write one JSON
+ * object per `data:` line and terminate with `data: [DONE]`, so this reads
+ * line-by-line rather than splitting on the `\n\n` frame separator: the
+ * encoder also emits a few single-`\n` `g:`-prefixed lines (the legacy Data
+ * Stream Protocol form for reasoning deltas) that a frame-split would glue
+ * onto the next event. Non-`data:` lines are skipped, as is a `data:` payload
+ * that is not JSON — a malformed frame mid-stream must not destroy the frames
+ * around it.
+ *
+ * A module-level function, not a client method: the SDK's URL-conformance
+ * sweep enumerates every callable on the client and demands each one either
+ * issue a request or carry an explicit non-HTTP reason. A parser is neither.
+ */
+async function* parseEventStream(res: Response): AsyncIterable {
+ const body = res.body as (ReadableStream & AsyncIterable) | null | undefined;
+ if (!body) {
+ throw new Error('Streaming response carried no body — this runtime\'s fetch does not expose `Response.body`');
+ }
+
+ const decoder = new TextDecoder();
+ let buffer = '';
+
+ const emit = function* (chunk: string): Generator {
+ buffer += chunk;
+ let nl: number;
+ while ((nl = buffer.indexOf('\n')) !== -1) {
+ const line = buffer.slice(0, nl).trim();
+ buffer = buffer.slice(nl + 1);
+ if (!line.startsWith('data:')) continue; // blank separators, `g:` reasoning frames
+ const payload = line.slice(5).trim();
+ if (!payload || payload === '[DONE]') continue;
+ try {
+ yield JSON.parse(payload) as AiStreamChunk;
+ } catch {
+ // A frame the server did not finish writing, or a non-JSON payload.
+ }
+ }
+ };
+
+ // `getReader()` in the browser and modern Node; async iteration for the
+ // Node-stream bodies older fetch polyfills hand back.
+ if (typeof body.getReader === 'function') {
+ const reader = body.getReader();
+ try {
+ for (;;) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ yield* emit(decoder.decode(value, { stream: true }));
+ }
+ } finally {
+ reader.releaseLock?.();
+ }
+ } else {
+ for await (const value of body) {
+ yield* emit(decoder.decode(value as Uint8Array, { stream: true }));
+ }
+ }
+
+ yield* emit(decoder.decode());
+}
+
export class ObjectStackClient {
private baseUrl: string;
private token?: string;
@@ -3466,27 +3540,173 @@ export class ObjectStackClient {
}
};
- // The `ai` namespace is GONE in v17 (#3718).
- //
- // It held exactly three methods — `nlq`, `suggest`, `insights` — building
- // `/api/v1/ai/{nlq,suggest,insights}`. Nothing in any repo ever mounted those
- // paths: they were declared in `DEFAULT_AI_ROUTES` (which has no runtime
- // consumer) and typed as optional protocol methods (`aiNlq?` …) nothing
- // implements. Every call 404ed, from the first release that shipped them.
- //
- // What DOES exist is a different surface entirely, served by `service-ai`
- // (Cloud/EE): `POST /ai/chat`, `/ai/chat/stream`, `/ai/complete`,
- // `GET /ai/models`, and six `/ai/conversations` routes. The SDK expressed
- // none of them, so its AI namespace and the real AI surface were disjoint
- // sets. Ledgered in `cloud`: `packages/service-ai/src/ai-route-ledger.ts`.
- //
- // Deliberately removed rather than left deprecated: a typed method that
- // always throws is worse than no method — it costs a runtime round-trip to
- // discover, where absence is a compile error. Expressing the real surface is
- // tracked separately on #3718; it is a new API, not a rename of this one.
- //
- // For chat specifically the answer is unchanged: use the Vercel AI SDK
- // (`useChat()` from `@ai-sdk/react`) directly against the chat endpoint.
+ /**
+ * AI Services — the surface `service-ai` really mounts (#3718).
+ *
+ * ## What this namespace is, and what it replaced
+ *
+ * Until v17 `client.ai` held `nlq`, `suggest` and `insights`, building
+ * `/api/v1/ai/{nlq,suggest,insights}`. **No repo has ever mounted those
+ * paths**; every call 404ed from the first release that shipped them. They
+ * were deleted rather than implemented, because the AI service that was
+ * actually built serves a different surface entirely — the two sets were
+ * disjoint. The methods below are that surface: `POST /ai/chat` (JSON or
+ * streaming), `POST /ai/complete`, `GET /ai/models`, and the six
+ * `/ai/conversations` routes.
+ *
+ * ## Where the server lives
+ *
+ * `service-ai` is a **Cloud/EE package in the `cloud` repo**. This repo's
+ * dispatcher only proxies `/api/v1/ai/**` to whatever `buildAIRoutes()`
+ * mounted, and 404s `AI service is not configured` when the service is
+ * absent (the open-source default) — so treat every method here as
+ * plugin-provided and check `discovery.services` first.
+ *
+ * That split is also why the guard for these URLs lives on the other side of
+ * the repo boundary: `cloud`'s `packages/service-ai/src/ai-route-ledger.ts`
+ * enumerates the table `buildAIRoutes()` returns and drives this namespace
+ * against it, so a method here that stops resolving fails a test there.
+ *
+ * ## Chat, and `useChat`
+ *
+ * `useChat()` from `@ai-sdk/react` remains the right client for a React chat
+ * UI — it speaks the same UI Message Stream Protocol {@link chatStream}
+ * parses, and it owns message state. These methods exist for everything that
+ * is not a React component: server-side callers, jobs, CLIs, tests.
+ */
+ ai = {
+ /**
+ * Chat completion, returned as JSON.
+ *
+ * Sends `stream: false` — the endpoint streams by default, so the flag is
+ * forced here rather than left to the caller. Tools are resolved
+ * server-side before the reply comes back, and the turn is persisted to
+ * `conversationId` (auto-created and echoed back when omitted).
+ */
+ chat: async (request: AiChatRequest): Promise => {
+ const route = this.getRoute('ai');
+ const res = await this.fetch(`${this.baseUrl}${route}/chat`, {
+ method: 'POST',
+ body: JSON.stringify({ ...request, stream: false }),
+ });
+ return this.unwrapResponse(res);
+ },
+
+ /**
+ * Chat completion as a stream of {@link AiStreamChunk} frames (the Vercel
+ * UI Message Stream Protocol).
+ *
+ * Returns a promise for an async iterable rather than being an async
+ * generator itself, so the request is issued — and an HTTP error thrown —
+ * when you call it, not when you first iterate.
+ *
+ * ```ts
+ * for await (const frame of await client.ai.chatStream({ messages })) {
+ * if (frame.type === 'text-delta') process.stdout.write(frame.delta);
+ * }
+ * ```
+ */
+ chatStream: async (request: AiChatRequest): Promise> => {
+ const route = this.getRoute('ai');
+ const res = await this.fetch(`${this.baseUrl}${route}/chat`, {
+ method: 'POST',
+ headers: { 'Accept': 'text/event-stream' },
+ body: JSON.stringify({ ...request, stream: true }),
+ });
+ return parseEventStream(res);
+ },
+
+ /** Single-shot text completion. */
+ complete: async (request: AiCompleteRequest): Promise => {
+ const route = this.getRoute('ai');
+ const res = await this.fetch(`${this.baseUrl}${route}/complete`, {
+ method: 'POST',
+ body: JSON.stringify(request),
+ });
+ return this.unwrapResponse(res);
+ },
+
+ /**
+ * Models this environment offers in the chat model picker (ADR-0028) —
+ * plan-filtered, with the default flagged. Populate a model picker from
+ * this rather than hard-coding ids.
+ */
+ models: async (): Promise => {
+ const route = this.getRoute('ai');
+ const res = await this.fetch(`${this.baseUrl}${route}/models`);
+ return this.unwrapResponse(res);
+ },
+
+ /**
+ * Persistent conversations.
+ *
+ * Every route is scoped to the authenticated user server-side: `create`
+ * binds the conversation to the caller and the rest 403 on someone else's.
+ * `userId` in a request body is ignored — it is not a way to act for
+ * another user.
+ */
+ conversations: {
+ /** Create a conversation. */
+ create: async (request?: CreateAiConversationRequest): Promise => {
+ const route = this.getRoute('ai');
+ const res = await this.fetch(`${this.baseUrl}${route}/conversations`, {
+ method: 'POST',
+ body: JSON.stringify(request ?? {}),
+ });
+ return this.unwrapResponse(res);
+ },
+
+ /** List the caller's conversations, newest first. */
+ list: async (options?: ListAiConversationsRequest): Promise => {
+ const route = this.getRoute('ai');
+ const params = new URLSearchParams();
+ if (options?.agentId) params.set('agentId', options.agentId);
+ if (options?.limit !== undefined) params.set('limit', String(options.limit));
+ if (options?.cursor) params.set('cursor', options.cursor);
+ const qs = params.toString();
+ const res = await this.fetch(`${this.baseUrl}${route}/conversations${qs ? `?${qs}` : ''}`);
+ const body = await this.unwrapResponse(res);
+ return body?.conversations ?? [];
+ },
+
+ /** Get one conversation with its full message history. */
+ get: async (id: string): Promise => {
+ const route = this.getRoute('ai');
+ const res = await this.fetch(`${this.baseUrl}${route}/conversations/${encodeURIComponent(id)}`);
+ return this.unwrapResponse(res);
+ },
+
+ /** Update mutable fields. At least one of `title` / `metadata` is required. */
+ update: async (id: string, patch: UpdateAiConversationRequest): Promise => {
+ const route = this.getRoute('ai');
+ const res = await this.fetch(`${this.baseUrl}${route}/conversations/${encodeURIComponent(id)}`, {
+ method: 'PATCH',
+ body: JSON.stringify(patch),
+ });
+ return this.unwrapResponse(res);
+ },
+
+ /** Delete a conversation and its messages. */
+ delete: async (id: string): Promise<{ deleted: boolean }> => {
+ const route = this.getRoute('ai');
+ const res = await this.fetch(`${this.baseUrl}${route}/conversations/${encodeURIComponent(id)}`, {
+ method: 'DELETE',
+ });
+ if (res.status === 204) return { deleted: true };
+ return this.unwrapResponse<{ deleted: boolean }>(res);
+ },
+
+ /** Append a message; returns the updated conversation. */
+ addMessage: async (id: string, message: AiMessage): Promise => {
+ const route = this.getRoute('ai');
+ const res = await this.fetch(`${this.baseUrl}${route}/conversations/${encodeURIComponent(id)}/messages`, {
+ method: 'POST',
+ body: JSON.stringify(message),
+ });
+ return this.unwrapResponse(res);
+ },
+ },
+ };
/**
* Internationalization Services
@@ -4503,12 +4723,17 @@ export type {
RegisterDeviceRequest,
RegisterDeviceResponse,
ListNotificationsResponse,
- AiNlqRequest,
- AiNlqResponse,
- AiSuggestRequest,
- AiSuggestResponse,
- AiInsightsRequest,
- AiInsightsResponse,
+ AiMessage,
+ AiChatRequest,
+ AiChatResponse,
+ AiStreamChunk,
+ AiCompleteRequest,
+ AiModelsResponse,
+ AiConversation,
+ CreateAiConversationRequest,
+ ListAiConversationsRequest,
+ ListAiConversationsResponse,
+ UpdateAiConversationRequest,
GetLocalesResponse,
GetTranslationsResponse,
GetFieldLabelsResponse,
diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts
index 24958219a6..740b23d9fa 100644
--- a/packages/runtime/src/route-ledger.ts
+++ b/packages/runtime/src/route-ledger.ts
@@ -41,12 +41,14 @@
* The last 3 were the `ai.*` methods on `* /ai/**`, and enumerating THAT
* family settled the question of how much a `**` row is worth: not "weaker
* evidence" but, in this case, WRONG evidence. `service-ai` (a Cloud/EE
- * package in the `cloud` repo) mounts 12 routes and not one of them is the
- * `/nlq` `/suggest` `/insights` the SDK calls — the wildcard had been
- * certifying three URLs that nothing anywhere serves (#3718). The capstone now
- * exempts `/api/v1/ai/` by prefix like the control plane, the real table is
- * ledgered in `cloud`, and the wildcard-only bound is **0**: every matched call
- * rests on an exact enumerated route.
+ * package in the `cloud` repo) mounts 12 routes and not one of them was the
+ * `/nlq` `/suggest` `/insights` the SDK called — the wildcard had been
+ * certifying three URLs that nothing anywhere serves (#3718). Those three
+ * methods are gone and the SDK now expresses the real table instead. The
+ * capstone exempts `/api/v1/ai/` by prefix like the control plane — bounded to
+ * the `ai.*` namespace, with the reachability check living in `cloud` next to
+ * the routes — and the wildcard-only bound is **0**: every matched call rests
+ * on an exact enumerated route.
*
* This module is runtime-internal (not exported from the package index): it is
* the guard's data, not public API. Promotion to `@objectstack/spec` is a
@@ -193,7 +195,7 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [
// ── ai (dynamic route table, owned by another repo) ───────────────────────
{ route: '* /ai/**', domain: '/ai', disposition: 'dynamic',
- note: 'routes come from service-ai buildAIRoutes() at plugin start — service-ai is a Cloud/EE package in the `cloud` repo, so this repo cannot enumerate them and the dispatcher only proxies (or 404s "AI service is not configured"). Enumerated on the other side of that boundary since #3718: cloud packages/service-ai/src/ai-route-ledger.ts. The previous note here claimed the client "expresses nlq/suggest/insights against the REST AI routes"; that was never verified and is FALSE — nothing mounts those three paths (#3718)' },
+ note: 'routes come from service-ai buildAIRoutes() at plugin start — service-ai is a Cloud/EE package in the `cloud` repo, so this repo cannot enumerate them and the dispatcher only proxies (or 404s "AI service is not configured"). Enumerated on the other side of that boundary since #3718: cloud packages/service-ai/src/ai-route-ledger.ts, whose conformance test drives client.ai.* against the table buildAIRoutes() really returns. The client now expresses that table — ai.chat / ai.chatStream / ai.complete / ai.models / ai.conversations.* — but do NOT read a `sdk` disposition into this row: it stays `dynamic` because THIS repo still cannot see the routes. An earlier note here claimed the client "expresses nlq/suggest/insights against the REST AI routes"; that was never verified and was FALSE — nothing has ever mounted those three paths, and both they and the methods calling them are gone (#3718)' },
// ── meta (legacy chain) ───────────────────────────────────────────────────
{ route: 'GET /meta', domain: '/meta', disposition: 'sdk', client: 'meta.getTypes' },
diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json
index 1cb5316e24..1feb537caf 100644
--- a/packages/spec/api-surface.json
+++ b/packages/spec/api-surface.json
@@ -2191,19 +2191,20 @@
"./api": [
"AckMessage (type)",
"AckMessageSchema (const)",
- "AiInsightsRequest (type)",
- "AiInsightsRequestSchema (const)",
- "AiInsightsResponse (type)",
- "AiInsightsResponseSchema (const)",
- "AiNlqRequest (type)",
- "AiNlqRequestSchema (const)",
- "AiNlqResponse (type)",
- "AiNlqResponseSchema (const)",
- "AiProtocol (interface)",
- "AiSuggestRequest (type)",
- "AiSuggestRequestSchema (const)",
- "AiSuggestResponse (type)",
- "AiSuggestResponseSchema (const)",
+ "AiChatRequest (type)",
+ "AiChatRequestSchema (const)",
+ "AiChatResponse (type)",
+ "AiChatResponseSchema (const)",
+ "AiCompleteRequest (type)",
+ "AiCompleteRequestSchema (const)",
+ "AiConversation (type)",
+ "AiConversationSchema (const)",
+ "AiMessage (type)",
+ "AiMessageSchema (const)",
+ "AiModelsResponse (type)",
+ "AiModelsResponseSchema (const)",
+ "AiStreamChunk (type)",
+ "AiStreamChunkSchema (const)",
"AnalyticsEndpoint (type)",
"AnalyticsMetadataResponse (type)",
"AnalyticsMetadataResponseSchema (const)",
@@ -2335,6 +2336,8 @@
"ConceptListResponse (type)",
"ConceptListResponseSchema (const)",
"ConflictResolutionStrategy (type)",
+ "CreateAiConversationRequest (type)",
+ "CreateAiConversationRequestSchema (const)",
"CreateDataRequest (type)",
"CreateDataRequestSchema (const)",
"CreateDataResponse (type)",
@@ -2377,7 +2380,6 @@
"CursorMessageSchema (const)",
"CursorPosition (type)",
"CursorPositionSchema (const)",
- "DEFAULT_AI_ROUTES (const)",
"DEFAULT_ANALYTICS_ROUTES (const)",
"DEFAULT_AUTOMATION_ROUTES (const)",
"DEFAULT_BATCH_ROUTES (const)",
@@ -2639,6 +2641,10 @@
"InstallPackageResponse (type)",
"InstallPackageResponseSchema (const)",
"InstalledPackage (type)",
+ "ListAiConversationsRequest (type)",
+ "ListAiConversationsRequestSchema (const)",
+ "ListAiConversationsResponse (type)",
+ "ListAiConversationsResponseSchema (const)",
"ListExportJobsRequest (type)",
"ListExportJobsRequestSchema (const)",
"ListExportJobsResponse (type)",
@@ -2970,6 +2976,8 @@
"UnsubscribeMessageSchema (const)",
"UnsubscribeRequest (type)",
"UnsubscribeRequestSchema (const)",
+ "UpdateAiConversationRequest (type)",
+ "UpdateAiConversationRequestSchema (const)",
"UpdateDataRequest (type)",
"UpdateDataRequestSchema (const)",
"UpdateDataResponse (type)",
diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json
index 241453face..019cc3b1c5 100644
--- a/packages/spec/json-schema.manifest.json
+++ b/packages/spec/json-schema.manifest.json
@@ -69,12 +69,13 @@
"ai/VectorStore",
"ai/VectorStoreProvider",
"api/AckMessage",
- "api/AiInsightsRequest",
- "api/AiInsightsResponse",
- "api/AiNlqRequest",
- "api/AiNlqResponse",
- "api/AiSuggestRequest",
- "api/AiSuggestResponse",
+ "api/AiChatRequest",
+ "api/AiChatResponse",
+ "api/AiCompleteRequest",
+ "api/AiConversation",
+ "api/AiMessage",
+ "api/AiModelsResponse",
+ "api/AiStreamChunk",
"api/AnalyticsEndpoint",
"api/AnalyticsMetadataResponse",
"api/AnalyticsQueryRequest",
@@ -139,6 +140,7 @@
"api/CompleteUploadRequest",
"api/ConceptListResponse",
"api/ConflictResolutionStrategy",
+ "api/CreateAiConversationRequest",
"api/CreateDataRequest",
"api/CreateDataResponse",
"api/CreateExportJobRequest",
@@ -288,6 +290,8 @@
"api/InitiateChunkedUploadResponse",
"api/InstallPackageRequest",
"api/InstallPackageResponse",
+ "api/ListAiConversationsRequest",
+ "api/ListAiConversationsResponse",
"api/ListExportJobsRequest",
"api/ListExportJobsResponse",
"api/ListFlowsRequest",
@@ -450,6 +454,7 @@
"api/UnregisterDeviceResponse",
"api/UnsubscribeMessage",
"api/UnsubscribeRequest",
+ "api/UpdateAiConversationRequest",
"api/UpdateDataRequest",
"api/UpdateDataResponse",
"api/UpdateFlowRequest",
diff --git a/packages/spec/src/api/plugin-rest-api.test.ts b/packages/spec/src/api/plugin-rest-api.test.ts
index 7f72f0fdac..5411f249fd 100644
--- a/packages/spec/src/api/plugin-rest-api.test.ts
+++ b/packages/spec/src/api/plugin-rest-api.test.ts
@@ -14,7 +14,6 @@ import {
DEFAULT_DATA_CRUD_ROUTES,
DEFAULT_BATCH_ROUTES,
DEFAULT_NOTIFICATION_ROUTES,
- DEFAULT_AI_ROUTES,
DEFAULT_I18N_ROUTES,
DEFAULT_ANALYTICS_ROUTES,
DEFAULT_AUTOMATION_ROUTES,
@@ -515,15 +514,18 @@ describe('plugin-rest-api.zod', () => {
expect(DEFAULT_NOTIFICATION_ROUTES.endpoints).toHaveLength(7);
});
- it('should validate DEFAULT_AI_ROUTES', () => {
- expect(DEFAULT_AI_ROUTES.prefix).toBe('/api/v1/ai');
- expect(DEFAULT_AI_ROUTES.service).toBe('ai');
- expect(DEFAULT_AI_ROUTES.category).toBe('ai');
- expect(DEFAULT_AI_ROUTES.methods).toContain('aiNlq');
- // aiChat removed — wire protocol aligned with Vercel AI SDK
- expect(DEFAULT_AI_ROUTES.methods).toContain('aiSuggest');
- expect(DEFAULT_AI_ROUTES.methods).toContain('aiInsights');
- expect(DEFAULT_AI_ROUTES.endpoints).toHaveLength(3);
+ it('declares no AI routes — this table cannot vouch for a Cloud/EE surface (#3718)', () => {
+ // DEFAULT_AI_ROUTES used to sit here declaring `/nlq`, `/suggest` and
+ // `/insights`, and this test asserted its shape — three endpoints no repo
+ // has ever mounted, checked for `toHaveLength(3)`. Shape is not
+ // existence. The AI service lives in `cloud`; its real table is
+ // enumerated by the ledger there, from `buildAIRoutes()` itself.
+ const registrations = getDefaultRouteRegistrations();
+ expect(registrations.map((r) => r.prefix)).not.toContain('/api/v1/ai');
+ expect(
+ registrations.flatMap((r) => r.methods ?? []),
+ 'the three handlers nothing ever implemented must not come back here',
+ ).not.toEqual(expect.arrayContaining(['aiNlq', 'aiSuggest', 'aiInsights']));
});
it('should validate DEFAULT_I18N_ROUTES', () => {
@@ -568,21 +570,20 @@ describe('plugin-rest-api.zod', () => {
expect(actionsEndpoint?.responseSchema).toBe('AutomationActionsResponseSchema');
});
- it('should return all 9 default registrations', () => {
+ it('should return all 8 default registrations', () => {
// Permission/View/Workflow/Realtime tables were deleted in #3612 —
- // no server ever mounted those routes.
+ // no server ever mounted those routes. AI went the same way in #3718.
const registrations = getDefaultRouteRegistrations();
- expect(registrations).toHaveLength(9);
+ expect(registrations).toHaveLength(8);
expect(registrations[0]).toBe(DEFAULT_DISCOVERY_ROUTES);
expect(registrations[1]).toBe(DEFAULT_METADATA_ROUTES);
expect(registrations[2]).toBe(DEFAULT_DATA_CRUD_ROUTES);
expect(registrations[3]).toBe(DEFAULT_BATCH_ROUTES);
expect(registrations[4]).toBe(DEFAULT_NOTIFICATION_ROUTES);
- expect(registrations[5]).toBe(DEFAULT_AI_ROUTES);
- expect(registrations[6]).toBe(DEFAULT_I18N_ROUTES);
- expect(registrations[7]).toBe(DEFAULT_ANALYTICS_ROUTES);
- expect(registrations[8]).toBe(DEFAULT_AUTOMATION_ROUTES);
+ expect(registrations[5]).toBe(DEFAULT_I18N_ROUTES);
+ expect(registrations[6]).toBe(DEFAULT_ANALYTICS_ROUTES);
+ expect(registrations[7]).toBe(DEFAULT_AUTOMATION_ROUTES);
});
it('should cover all protocol categories', () => {
@@ -594,7 +595,6 @@ describe('plugin-rest-api.zod', () => {
expect(categories).toContain('data');
expect(categories).toContain('batch');
expect(categories).toContain('notification');
- expect(categories).toContain('ai');
expect(categories).toContain('i18n');
expect(categories).toContain('analytics');
expect(categories).toContain('automation');
diff --git a/packages/spec/src/api/plugin-rest-api.zod.ts b/packages/spec/src/api/plugin-rest-api.zod.ts
index fedf82524f..1eded479c2 100644
--- a/packages/spec/src/api/plugin-rest-api.zod.ts
+++ b/packages/spec/src/api/plugin-rest-api.zod.ts
@@ -1083,69 +1083,26 @@ export const DEFAULT_NOTIFICATION_ROUTES: RestApiRouteRegistration = {
// AI Routes
// ==========================================
-/**
- * Default AI Routes
- * Standard routes for AI operations (NLQ, Chat, Suggest, Insights)
- */
-export const DEFAULT_AI_ROUTES: RestApiRouteRegistration = {
- prefix: '/api/v1/ai',
- service: 'ai',
- category: 'ai',
- methods: ['aiNlq', 'aiSuggest', 'aiInsights'],
- authRequired: true,
- endpoints: [
- {
- method: 'POST',
- path: '/nlq',
- handler: 'aiNlq',
- category: 'ai',
- public: false,
- summary: 'Natural language query',
- description: 'Converts a natural language query to a structured query AST',
- tags: ['AI'],
- requestSchema: 'AiNlqRequestSchema',
- responseSchema: 'AiNlqResponseSchema',
- timeout: 30000,
- cacheable: false,
- },
- // AI chat route removed — wire protocol aligned with Vercel AI SDK.
- // The chat endpoint should use Vercel's `toDataStreamResponse()` directly.
- {
- method: 'POST',
- path: '/suggest',
- handler: 'aiSuggest',
- category: 'ai',
- public: false,
- summary: 'Get AI-powered suggestions',
- description: 'Returns AI-generated field value suggestions based on context',
- tags: ['AI'],
- requestSchema: 'AiSuggestRequestSchema',
- responseSchema: 'AiSuggestResponseSchema',
- timeout: 15000,
- cacheable: false,
- },
- {
- method: 'POST',
- path: '/insights',
- handler: 'aiInsights',
- category: 'ai',
- public: false,
- summary: 'Get AI-generated insights',
- description: 'Returns AI-generated insights (summaries, trends, anomalies, recommendations)',
- tags: ['AI'],
- requestSchema: 'AiInsightsRequestSchema',
- responseSchema: 'AiInsightsResponseSchema',
- timeout: 60000,
- cacheable: false,
- },
- ],
- middleware: [
- { name: 'auth', type: 'authentication', enabled: true, order: 10 },
- { name: 'validation', type: 'validation', enabled: true, order: 20 },
- { name: 'response_envelope', type: 'transformation', enabled: true, order: 100 },
- { name: 'error_handler', type: 'error', enabled: true, order: 200 },
- ],
-};
+// `DEFAULT_AI_ROUTES` is GONE (#3718).
+//
+// It declared `POST /api/v1/ai/{nlq,suggest,insights}` with handlers
+// `aiNlq` / `aiSuggest` / `aiInsights`. Nothing ever mounted those paths and
+// nothing ever implemented those handlers, so all three 404ed for the whole
+// life of the declaration — while `client.ai.nlq/suggest/insights` called them
+// and this table made them look registered.
+//
+// It could not have been otherwise: this registration table has **no runtime
+// consumer**. Only `getDefaultRouteRegistrations()` returned it, and only this
+// package's own tests read that. Re-declaring the routes that DO exist here
+// would recreate the same illusion, because the AI service is a Cloud/EE
+// package (`service-ai`, in the `cloud` repo) and this repo's dispatcher only
+// proxies `/api/v1/ai/**` to whatever `buildAIRoutes()` mounted.
+//
+// The real table is enumerated where it is mounted — `cloud`'s
+// `packages/service-ai/src/ai-route-ledger.ts`, whose conformance test reads
+// `buildAIRoutes()` directly and drives `client.ai.*` against it. The wire
+// shapes are `Ai*Schema` in `protocol.zod.ts`; the docs table is
+// `content/docs/api/plugin-endpoints.mdx`.
// ==========================================
// i18n Routes
@@ -1336,16 +1293,19 @@ export const RestApiRouteRegistration = Object.assign(RestApiRouteRegistrationSc
* Get all default route registrations.
* Returns the complete set of standard REST API routes covering all protocol namespaces.
*
- * Route groups (13 total):
+ * Route groups (8 total):
* 1. Discovery - API capabilities and routing info
* 2. Metadata - Object/field schema CRUD
* 3. Data CRUD - Record operations
* 4. Batch - Bulk operations
* 5. Notification - Push notifications and preferences
- * 6. AI - NLQ, chat, suggestions, insights
- * 7. i18n - Locales and translations
- * 8. Analytics - BI queries and metadata
- * 9. Automation - Trigger flows and scripts
+ * 6. i18n - Locales and translations
+ * 7. Analytics - BI queries and metadata
+ * 8. Automation - Trigger flows and scripts
+ *
+ * AI is deliberately absent: its routes are mounted by a Cloud/EE package in
+ * the `cloud` repo, never from here, and the group that used to sit at #6
+ * declared three endpoints nothing has ever served (#3718).
*/
export function getDefaultRouteRegistrations(): RestApiRouteRegistration[] {
return [
@@ -1354,7 +1314,6 @@ export function getDefaultRouteRegistrations(): RestApiRouteRegistration[] {
DEFAULT_DATA_CRUD_ROUTES,
DEFAULT_BATCH_ROUTES,
DEFAULT_NOTIFICATION_ROUTES,
- DEFAULT_AI_ROUTES,
DEFAULT_I18N_ROUTES,
DEFAULT_ANALYTICS_ROUTES,
DEFAULT_AUTOMATION_ROUTES,
diff --git a/packages/spec/src/api/protocol.test.ts b/packages/spec/src/api/protocol.test.ts
index d8dd372f4a..753abe0b92 100644
--- a/packages/spec/src/api/protocol.test.ts
+++ b/packages/spec/src/api/protocol.test.ts
@@ -50,12 +50,13 @@ import {
ListNotificationsResponseSchema,
MarkNotificationsReadRequestSchema,
// AI
- AiNlqRequestSchema,
- AiNlqResponseSchema,
- AiSuggestRequestSchema,
- AiSuggestResponseSchema,
- AiInsightsRequestSchema,
- AiInsightsResponseSchema,
+ AiChatRequestSchema,
+ AiChatResponseSchema,
+ AiCompleteRequestSchema,
+ AiModelsResponseSchema,
+ CreateAiConversationRequestSchema,
+ ListAiConversationsResponseSchema,
+ UpdateAiConversationRequestSchema,
// i18n
GetLocalesResponseSchema,
GetTranslationsRequestSchema,
@@ -278,21 +279,52 @@ describe('ObjectStack Protocol', () => {
expect(MarkNotificationsReadRequestSchema.safeParse({ ids: ['n1', 'n2'] }).success).toBe(true);
});
+ /**
+ * These replace the `AiNlq*` / `AiSuggest*` / `AiInsights*` cases (#3718).
+ * Those parsed cleanly for years against endpoints no repo has ever mounted
+ * — a schema is a shape, never evidence that anything serves it. What is
+ * asserted here is the shape of the routes `service-ai` really mounts, which
+ * `cloud`'s ledger checks against `buildAIRoutes()` itself.
+ */
it('validates AI operations', () => {
- expect(AiNlqRequestSchema.safeParse({ query: 'show me all open tasks', object: 'task' }).success).toBe(true);
- expect(AiNlqResponseSchema.safeParse({
- query: { object: 'task', where: { status: 'open' } },
- explanation: 'Find all tasks with open status', confidence: 0.92,
+ // chat — Vercel `useChat` flat form, and the JSON (stream:false) reply
+ expect(AiChatRequestSchema.safeParse({
+ messages: [{ role: 'user', content: 'how many open orders?' }],
+ system: 'You are a helpful assistant', model: 'gpt-4o-mini', stream: false,
}).success).toBe(true);
- // AiChatRequestSchema/AiChatResponseSchema removed — chat protocol aligned with Vercel AI SDK
- expect(AiSuggestRequestSchema.safeParse({ object: 'task', field: 'priority', partial: 'hi' }).success).toBe(true);
- expect(AiSuggestResponseSchema.safeParse({
- suggestions: [{ value: 'high', label: 'High', confidence: 0.95, reason: 'Matches partial input' }],
+ // v6 `parts` messages carry no `content` at all — the routes accept them
+ expect(AiChatRequestSchema.safeParse({
+ messages: [{ role: 'assistant', parts: [{ type: 'text', text: 'hi' }] }],
}).success).toBe(true);
- expect(AiInsightsRequestSchema.safeParse({ object: 'task', type: 'trends' }).success).toBe(true);
- expect(AiInsightsResponseSchema.safeParse({
- insights: [{ type: 'trends', title: 'Task Completion Rate', description: 'Completion rate increased by 15% this month', confidence: 0.88 }],
+ expect(AiChatRequestSchema.safeParse({ messages: [] }).success, 'the routes 400 an empty message list').toBe(false);
+ expect(AiChatResponseSchema.safeParse({
+ content: '42 open orders', model: 'gpt-4o-mini',
+ usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 }, conversationId: 'conv_1',
}).success).toBe(true);
+
+ // complete
+ expect(AiCompleteRequestSchema.safeParse({ prompt: 'Summarise:', options: { maxTokens: 64 } }).success).toBe(true);
+ expect(AiCompleteRequestSchema.safeParse({}).success).toBe(false);
+
+ // models — both live shapes: the ADR-0028 allowlist and the bare-id fallback
+ expect(AiModelsResponseSchema.safeParse({
+ models: [{ id: 'gpt-4o-mini', label: 'GPT-4o mini', default: true }], defaultModel: 'gpt-4o-mini',
+ }).success).toBe(true);
+ expect(AiModelsResponseSchema.safeParse({ models: ['gpt-4o-mini'] }).success).toBe(true);
+
+ // conversations
+ expect(CreateAiConversationRequestSchema.safeParse({ title: 'Q3 pipeline', metadata: { source: 'sdk' } }).success).toBe(true);
+ expect(ListAiConversationsResponseSchema.safeParse({
+ conversations: [{
+ id: 'conv_1', messages: [{ role: 'user', content: 'hi' }],
+ createdAt: '2026-07-27T10:00:00Z', updatedAt: '2026-07-27T10:00:00Z',
+ }],
+ }).success).toBe(true);
+ expect(UpdateAiConversationRequestSchema.safeParse({ title: 'Renamed' }).success).toBe(true);
+ expect(
+ UpdateAiConversationRequestSchema.safeParse({}).success,
+ 'PATCH with neither title nor metadata is a 400 on the wire',
+ ).toBe(false);
});
it('validates i18n operations', () => {
diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts
index 3219e6e8c6..ad50d626ba 100644
--- a/packages/spec/src/api/protocol.zod.ts
+++ b/packages/spec/src/api/protocol.zod.ts
@@ -889,55 +889,151 @@ export const MarkAllNotificationsReadResponseSchema = lazySchema(() => z.object(
// ==========================================
// AI Operations
// ==========================================
+//
+// THESE ARE THE SHAPES OF THE ROUTES THAT EXIST (#3718).
+//
+// What used to live here — `AiNlq*`, `AiSuggest*`, `AiInsights*` — described
+// `/api/v1/ai/{nlq,suggest,insights}`, three endpoints **no repo has ever
+// mounted**. They were declared here, registered in `DEFAULT_AI_ROUTES` (a
+// table with no runtime consumer), typed as optional protocol methods nothing
+// implements, and called by `client.ai.*`. Every call 404ed for the whole life
+// of the namespace; the SDK's AI surface and the real one were disjoint sets.
+//
+// The AI service itself is a Cloud/EE package (`service-ai`, in the `cloud`
+// repo) — this repo's dispatcher only proxies `/api/v1/ai/**` to whatever
+// `buildAIRoutes()` mounted, or 404s "AI service is not configured". So these
+// schemas deliberately describe **the wire**, not a protocol this repo serves:
+// they are the one shape `client.ai.*` and cloud's route handlers both read.
+// The mounted table's reviewed dispositions live in `cloud`
+// (`packages/service-ai/src/ai-route-ledger.ts`), which drives this SDK
+// against the routes it really returns.
-export const AiNlqRequestSchema = lazySchema(() => z.object({
- query: z.string().describe('Natural language query string'),
- object: z.string().optional().describe('Target object context'),
- conversationId: z.string().optional().describe('Conversation ID for multi-turn queries'),
-}));
+/**
+ * One conversation message on the wire.
+ *
+ * Deliberately permissive about `content`: the AI routes accept a plain string
+ * (legacy), a content-part array, and the Vercel AI SDK v6 `parts` form where
+ * `content` may be absent entirely. `role` is the part the routes actually
+ * validate, so it is the part constrained here — narrowing `content` further
+ * would reject payloads the server accepts.
+ */
+export const AiMessageSchema = lazySchema(() => z.object({
+ role: z.enum(['system', 'user', 'assistant', 'tool']).describe('Message role'),
+ content: z.unknown().optional().describe('Message content: a string, or an array of content parts'),
+ parts: z.array(z.unknown()).optional().describe('Vercel AI SDK v6 message parts (alternative to `content`)'),
+}).passthrough()); // adapters carry extra per-message fields (id, createdAt, …)
-export const AiNlqResponseSchema = lazySchema(() => z.object({
- query: z.unknown().describe('Generated structured query (AST)'),
- explanation: z.string().optional().describe('Human-readable explanation of the query'),
- confidence: z.number().min(0).max(1).optional().describe('Confidence score (0-1)'),
- suggestions: z.array(z.string()).optional().describe('Suggested follow-up queries'),
+/**
+ * `POST /api/v1/ai/chat` — dual-mode. `stream: false` returns
+ * {@link AiChatResponseSchema} as JSON; anything else streams the Vercel UI
+ * Message Stream Protocol (SSE with JSON payloads).
+ *
+ * Flat `model`/`temperature`/`maxTokens` are the Vercel `useChat` shape and
+ * take precedence over the same keys nested under `options`.
+ */
+export const AiChatRequestSchema = lazySchema(() => z.object({
+ messages: z.array(AiMessageSchema).min(1).describe('Conversation messages (at least one)'),
+ system: z.string().optional().describe('System prompt, prepended as a system message'),
+ model: z.string().optional().describe('Model id override'),
+ temperature: z.number().optional().describe('Sampling temperature'),
+ maxTokens: z.number().int().positive().optional().describe('Maximum tokens to generate'),
+ stream: z.boolean().optional().describe('false → JSON response; otherwise the UI Message Stream Protocol'),
+ conversationId: z.string().optional().describe('Conversation to persist this turn into (auto-created when omitted)'),
+ turnId: z.string().optional().describe('Stable per-turn idempotency key (ADR-0013 D1)'),
+ options: z.record(z.string(), z.unknown()).optional().describe('Legacy nested request options'),
+}));
+
+/** JSON body of a non-streaming chat/completion turn — the `AIResult` shape. */
+export const AiChatResponseSchema = lazySchema(() => z.object({
+ content: z.string().describe('Generated text'),
+ model: z.string().optional().describe('Model that produced it'),
+ toolCalls: z.array(z.unknown()).optional().describe('Tool calls the model requested (Vercel `ToolCallPart`)'),
+ usage: z.object({
+ promptTokens: z.number().describe('Tokens consumed by the prompt'),
+ completionTokens: z.number().describe('Tokens generated'),
+ totalTokens: z.number().describe('prompt + completion'),
+ }).optional().describe('Token usage'),
+ conversationId: z.string().optional().describe('Conversation the turn was persisted into'),
+}));
+
+/** `POST /api/v1/ai/complete` — single-shot text completion. */
+export const AiCompleteRequestSchema = lazySchema(() => z.object({
+ prompt: z.string().describe('Prompt text'),
+ options: z.record(z.string(), z.unknown()).optional().describe('Request options (model, temperature, maxTokens, …)'),
+}));
+
+/** `GET /api/v1/ai/models` — the environment's plan-filtered picker list (ADR-0028). */
+export const AiModelsResponseSchema = lazySchema(() => z.object({
+ /**
+ * Objects when the service exposes the ADR-0028 allowlist; bare ids when it
+ * falls back to the adapter's `listModels()`. Both shapes are live, so both
+ * are declared rather than one being asserted and the other 404-by-parse.
+ */
+ models: z.array(z.union([
+ z.string(),
+ z.object({
+ id: z.string().describe('Model id'),
+ label: z.string().describe('Display label for the picker'),
+ default: z.boolean().describe('Whether this is the environment default'),
+ }),
+ ])).describe('Models this environment offers'),
+ defaultModel: z.string().optional().describe('Default model id, when the service reports one'),
+}));
+
+/** A persisted AI conversation, as the `/ai/conversations` routes return it. */
+export const AiConversationSchema = lazySchema(() => z.object({
+ id: z.string().describe('Conversation id'),
+ title: z.string().optional().describe('Title / summary'),
+ agentId: z.string().optional().describe('Agent this conversation is bound to'),
+ userId: z.string().optional().describe('Owning user'),
+ messages: z.array(AiMessageSchema).describe('Message history'),
+ createdAt: z.string().describe('Creation timestamp (ISO 8601)'),
+ updatedAt: z.string().describe('Last update timestamp (ISO 8601)'),
+ metadata: z.record(z.string(), z.unknown()).optional().describe('Conversation metadata'),
}));
-// AiChatRequestSchema and AiChatResponseSchema have been removed.
-// The AI chat wire protocol is now fully aligned with the Vercel AI SDK (`ai`).
-// Frontend consumers should use `@ai-sdk/react/useChat` directly.
-// See: https://ai-sdk.dev/docs
-
-export const AiSuggestRequestSchema = lazySchema(() => z.object({
- object: z.string().describe('Object name for context'),
- field: z.string().optional().describe('Field to suggest values for'),
- recordId: z.string().optional().describe('Record ID for context'),
- partial: z.string().optional().describe('Partial input for completion'),
+/**
+ * `POST /api/v1/ai/conversations`. `userId` is NOT accepted from the caller —
+ * the route overwrites it with the authenticated actor.
+ */
+export const CreateAiConversationRequestSchema = lazySchema(() => z.object({
+ title: z.string().optional().describe('Initial title'),
+ agentId: z.string().optional().describe('Agent to bind the conversation to'),
+ metadata: z.record(z.string(), z.unknown()).optional().describe('Conversation metadata'),
}));
-export const AiSuggestResponseSchema = lazySchema(() => z.object({
- suggestions: z.array(z.object({
- value: z.unknown().describe('Suggested value'),
- label: z.string().describe('Display label'),
- confidence: z.number().min(0).max(1).optional().describe('Confidence score (0-1)'),
- reason: z.string().optional().describe('Reason for this suggestion'),
- })).describe('Suggested values'),
+/** `GET /api/v1/ai/conversations` query — scoped to the authenticated user. */
+export const ListAiConversationsRequestSchema = lazySchema(() => z.object({
+ agentId: z.string().optional().describe('Filter by agent'),
+ limit: z.number().int().positive().optional().describe('Maximum conversations to return'),
+ cursor: z.string().optional().describe('Pagination cursor'),
}));
-export const AiInsightsRequestSchema = lazySchema(() => z.object({
- object: z.string().describe('Object name to analyze'),
- recordId: z.string().optional().describe('Specific record to analyze'),
- type: z.enum(['summary', 'trends', 'anomalies', 'recommendations']).optional().describe('Type of insight'),
+export const ListAiConversationsResponseSchema = lazySchema(() => z.object({
+ conversations: z.array(AiConversationSchema).describe('Matching conversations'),
}));
-export const AiInsightsResponseSchema = lazySchema(() => z.object({
- insights: z.array(z.object({
- type: z.string().describe('Insight type'),
- title: z.string().describe('Insight title'),
- description: z.string().describe('Detailed description'),
- confidence: z.number().min(0).max(1).optional().describe('Confidence score (0-1)'),
- data: z.record(z.string(), z.unknown()).optional().describe('Supporting data'),
- })).describe('Generated insights'),
+/**
+ * One frame of a streaming chat response.
+ *
+ * `POST /api/v1/ai/chat` (streaming mode) speaks the Vercel **UI Message
+ * Stream Protocol**: SSE lines carrying JSON objects that always have a
+ * `type` — `start`, `text-start`, `text-delta`, `tool-input-available`,
+ * `tool-output-available`, `error`, `finish-step`, `finish` — terminated by a
+ * literal `data: [DONE]`. `POST /api/v1/ai/chat/stream` uses the same SSE
+ * envelope for raw `TextStreamPart` events. Only `type` is guaranteed across
+ * both, so only `type` is declared; the rest of each frame passes through.
+ */
+export const AiStreamChunkSchema = lazySchema(() => z.object({
+ type: z.string().describe('Frame type (text-delta, tool-input-available, finish, error, …)'),
+}).passthrough());
+
+/** `PATCH /api/v1/ai/conversations/:id` — at least one field is required. */
+export const UpdateAiConversationRequestSchema = lazySchema(() => z.object({
+ title: z.string().optional().describe('New title'),
+ metadata: z.record(z.string(), z.unknown()).optional().describe('New metadata'),
+}).refine((p) => p.title !== undefined || p.metadata !== undefined, {
+ message: 'at least one of title or metadata is required',
}));
// ==========================================
@@ -1099,12 +1195,17 @@ export type MarkAllNotificationsReadRequest = z.input;
// AI Types
-export type AiNlqRequest = z.input;
-export type AiNlqResponse = z.infer;
-export type AiSuggestRequest = z.input;
-export type AiSuggestResponse = z.infer;
-export type AiInsightsRequest = z.input;
-export type AiInsightsResponse = z.infer;
+export type AiMessage = z.input;
+export type AiChatRequest = z.input;
+export type AiChatResponse = z.infer;
+export type AiStreamChunk = z.infer;
+export type AiCompleteRequest = z.input;
+export type AiModelsResponse = z.infer;
+export type AiConversation = z.infer;
+export type CreateAiConversationRequest = z.input;
+export type ListAiConversationsRequest = z.input;
+export type ListAiConversationsResponse = z.infer;
+export type UpdateAiConversationRequest = z.input;
// i18n Types
export type GetLocalesRequest = z.input;
@@ -1254,12 +1355,19 @@ export interface NotificationProtocol {
markAllNotificationsRead?(request: MarkAllNotificationsReadRequest): Promise;
}
-/** AI (optional — chat is now handled by the Vercel AI SDK wire protocol). */
-export interface AiProtocol {
- aiNlq?(request: AiNlqRequest): Promise;
- aiSuggest?(request: AiSuggestRequest): Promise;
- aiInsights?(request: AiInsightsRequest): Promise;
-}
+// `AiProtocol` is GONE (#3718).
+//
+// It declared exactly three optional methods — `aiNlq?` / `aiSuggest?` /
+// `aiInsights?` — and no service in any repo implemented one. Nothing
+// dispatched through it either: `/api/v1/ai/**` is proxied straight to the
+// route handlers `service-ai` (Cloud/EE, in the `cloud` repo) builds, never
+// through a protocol object. An interface whose every member is optional and
+// unimplemented does not describe a server — it only makes one look declared.
+//
+// The real server-side contract for AI is `IAIService` +
+// `IAIConversationService` in `@objectstack/spec/contracts` (`ai-service.ts`),
+// which `service-ai` actually implements. The wire shapes those routes speak
+// are the `Ai*` schemas above.
/** Localization (optional). */
export interface I18nProtocol {
diff --git a/packages/spec/src/kernel/manifest.zod.ts b/packages/spec/src/kernel/manifest.zod.ts
index 1164259d08..6ea346d8fc 100644
--- a/packages/spec/src/kernel/manifest.zod.ts
+++ b/packages/spec/src/kernel/manifest.zod.ts
@@ -413,7 +413,7 @@ export const ManifestSchema = z.object({
*
* @example
* routes: [
- * { prefix: '/api/v1/ai', service: 'ai', methods: ['aiNlq', 'aiChat'] }
+ * { prefix: '/api/v1/i18n', service: 'i18n', methods: ['getLocales', 'getTranslations'] }
* ]
*/
routes: z.array(z.object({
@@ -423,7 +423,7 @@ export const ManifestSchema = z.object({
service: z.string().describe('Service name this plugin provides'),
/** Protocol method names implemented */
methods: z.array(z.string()).optional()
- .describe('Protocol method names implemented (e.g. ["aiNlq", "aiChat"])'),
+ .describe('Protocol method names implemented (e.g. ["getLocales", "getTranslations"])'),
})).optional().describe('API route contributions to HttpDispatcher'),
/**
diff --git a/scripts/role-word-baseline.json b/scripts/role-word-baseline.json
index f7bd9f4b19..be66ed04d9 100644
--- a/scripts/role-word-baseline.json
+++ b/scripts/role-word-baseline.json
@@ -1,6 +1,7 @@
{
"content/docs/ai/agents.mdx": 5,
"content/docs/ai/index.mdx": 2,
+ "content/docs/api/client-sdk.mdx": 2,
"content/docs/api/error-catalog.mdx": 1,
"content/docs/automation/approvals.mdx": 1,
"content/docs/concepts/architecture.mdx": 4,