openvibers/openvibe.sdk Commits

openvibe-sdk/chat: Vibe, support inboxes, feeds; createInboxVisitor and createFeedPublisher (#78)

OP OpenVibers committed 19 days ago · e0d85523fe · 7 files, +517 −8

* openvibe-sdk/chat: Vibe, support inboxes and feeds; createInboxVisitor (your own panel, no account) and createFeedPublisher (vibe coding events with a feed's key)

* createInboxVisitor.rate: the visitor rates a resolved conversation (Chat#148); config().inbox.people

* createInboxVisitor({ user }): a verified visitor (Chat's verified visitors) sends who they are with visit and send

* inboxes.identity, saved replies and webhooks in openvibe-sdk/chat

* support.* (a person's own conversations with sites' chats) and createInboxVisitor({ account })

* openvibe-sdk/chat: inbox actions (add, try, remove), assigning a conversation, and translation

* openvibe-sdk/chat: createInboxVisitor sendPicture (signed-in visitors), messages' account flag

Changes

ddiff --git a/README.md b/README.md
iindex b0bb124..8a6fb45 100644
---- a/README.md
++++ b/README.md
@@@ -177,7 +177,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `openvibe-sdk/search` | both | `createSearchClient(client, {actingSubject?})`: `query(text, {owner, type, lang, filter, facets, limit, cursor})`, `iterate(text, {…, max})`, `suggest(text, {owner, type, limit})`, `document(owner, type, id)` (null when missing or not visible) |
| `openvibe-sdk/limits` | node | `createActorLimiter({limits: {minute, hour, day}, actor?, maxActors?, onLimited?})` → `limits(name, {minute?, hour?, day?})` Express middleware: per-actor fixed windows at a capability boundary (service principal, else the signed-in person, else `ip:`), 429 problem+json `rate_limited` with Retry-After before the handler; `defaultActor(req)`, `.stats()` |
| `openvibe-sdk/account-data` | node | `createAccountData({db, service, tables: [{table, subject, value?, file?, columns?, erase?}], extraExport?, extraErase?})` → `.apply(event, {send})`, `.consumer({secrets, send, onEvent?})` (a POST /internal/events handler), `.exportPart(subject)`, `.erase(subjects)`, `.ensureSchema()`; `createNetworkSender({networkInternalUrl, clientId, clientSecret})`; `startSubscriptions({eventsUrl, endpoint, secret, networkInternalUrl, clientId, clientSecret})` (the two Events subscriptions, created at boot when missing): a service's share of account export and deletion (ADR-033), idempotent per export/deletion id |
--| `openvibe-sdk/chat` | both | `createChatClient(client)`: `global.send/history/search`, `rooms.*` (list, create, get, update, messages, send, deleteMessage, join, leave, read, members, setMember), `dms.*` (list, create, get, messages, send, deleteMessage, read, unread, blocks, block, unblock), `exportMine()` |
++| `openvibe-sdk/chat` | both | `createChatClient(client)`: `global.send/history/search`, `rooms.*` (list, create, get, update, messages, send, deleteMessage, join, leave, read, members, setMember), `dms.*` (list, create, get, messages, send, deleteMessage, read, unread, blocks, block, unblock), `exportMine()`, `vibe.*` (list, start, rename, remove: chats with Vibe), `inboxes.*` (list, create, get, update, remove, addMember, removeMember, conversations, messages, reply, setStatus, read, identity, replies, saveReply, deleteReply, webhooks, addWebhook, testWebhook, removeWebhook: a support inbox's team), `support.*` (list, messages, send: their own conversations with sites' chats), `feeds.*` (mine, create, update, rotateKey, remove, view, onLive). With no account: `createInboxVisitor(client, { inbox, token, user, account })` (visit, send, messages, handoff, rate: your own panel for an inbox, from any site) and `createFeedPublisher(client, { feed, key })` (hello, publish, claudeCode: vibe coding events with the feed's key) |
| `openvibe-sdk/ai` | both | `createAiClient(client)`: `runs.create(workflow, input, {wait, idempotencyKey, …})`, `runs.get/list/cancel/retry/citations/addCitations/waitFor`, `chat/generate/summarize/classify/extract/enrich/embed` |
| `openvibe-sdk/jobs` | both | `createJobsClient(client, {baseUrl?})`: `submit` (Idempotency-Key), `get`, `cancel`, `retry`, `reference`, `unreference`, `events` (SSE, reattaches), `wait`, `file` (raw Response); `isTerminal()`. Default origin: the Tools gateway's jobs facade |
| `openvibe-sdk/tools` | both | `createToolsClient(client, {baseUrl?, anonymousReads?})`: `list({family, q, execution, api, status})`, `get(id)`, `schema(id)`, `run(id, input, {files, waitMs, idempotencyKey, signal, timeoutMs})` (+ `.wait()`), `jobs`; `ToolRunError`, `isToolRunError()` |
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex d5d780e..65a8db8 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -2148,6 +2148,17 @@ function (module, exports) {
* Every call acts as the token's person (Chat has no service acting-for-someone mode). Writes are never
* retried (Chat does not deduplicate them); reads are. A missing or unreadable room or conversation is
* null from get().
++ *
++ * The rest of Chat as a platform (Chat's docs/platform.md): `vibe` (a person's chats with Vibe, OpenVibe's
++ * assistant), `inboxes` (support inboxes: the team's side) and `feeds` (live activity such as vibe coding: the
++ * owner's side). Two clients need no account at all and answer from any site, without cookies:
++ *
++ * const visitor = createInboxVisitor(client, { inbox: 'inb_…', token: saved }); // build your own chat panel
++ * const { token } = await visitor.visit(); // keep it: it is the visitor
++ * await visitor.send('Where is my order?', { page_url: location.href });
++ *
++ * const feed = createFeedPublisher(client, { feed: 'fed_…', key: feedKey }); // the feed's ovf_ key
++ * await feed.publish({ eventType: 'prompt', text: 'Add a dark mode' }); // or a list, or Live's envelopes
*/
const { isOpenVibeError } = __load(3);
@@@ -2160,6 +2171,8 @@ function createChatClient(client, defaults = {}) {
const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && (err.status === 404 || err.status === 403)) return null; throw err; });
const room = (slug) => `/api/chat/rooms/${enc(slug)}`;
const conv = (id) => `/api/dm/conversations/${enc(id)}`;
++ const inbox = (id) => `/api/chat/inboxes/${enc(id)}`;
++ const feed = (id) => `/api/chat/feeds/${enc(id)}`;
return {
global: {
@@@ -2203,10 +2216,161 @@ function createChatClient(client, defaults = {}) {
},
/** Every message you sent that is still visible: { username, total, truncated, messages } (JSON). */
exportMine: (o = {}) => call({ path: '/api/chat/me/export' }, o),
++ /** Vibe, OpenVibe's assistant: the person's chats with it (people only; an API token gets 403). */
++ vibe: {
++ /** { chats, allowance } */
++ list: (o = {}) => call({ path: '/api/chat/vibe' }, o),
++ /** A new chat; Vibe answers in its room (rooms.messages, or the room socket). → { room, message } */
++ start: (message, o = {}) => call({ method: 'POST', path: '/api/chat/vibe', json: { message }, ...once }, o),
++ rename: (slug, name, o = {}) => call({ method: 'PATCH', path: `/api/chat/vibe/${enc(slug)}`, json: { name }, ...once }, o),
++ remove: (slug, o = {}) => call({ method: 'DELETE', path: `/api/chat/vibe/${enc(slug)}`, ...once }, o),
++ },
++ /** Support inboxes, the team's side: Vibe answers visitors first, the inbox's people take over. */
++ inboxes: {
++ /** { inboxes }: those you work in, with waiting and open counts */
++ list: (o = {}) => call({ path: '/api/chat/inboxes' }, o),
++ /** → { inbox, embed: { script, panel } }; embed.script is the one tag a site adds */
++ create: (input, o = {}) => call({ method: 'POST', path: '/api/chat/inboxes', json: input, ...once }, o),
++ get: (id, o = {}) => orNull(call({ path: inbox(id) }, o)),
++ update: (id, patch, o = {}) => call({ method: 'PATCH', path: inbox(id), json: patch, ...once }, o),
++ remove: (id, o = {}) => call({ method: 'DELETE', path: inbox(id), ...once }, o),
++ addMember: (id, username, o = {}) => call({ method: 'POST', path: `${inbox(id)}/members`, json: { username }, ...once }, o),
++ removeMember: (id, userId, o = {}) => call({ method: 'DELETE', path: `${inbox(id)}/members/${enc(userId)}`, ...once }, o),
++ /** query: status = active | bot | waiting | open | resolved → { conversations } (waiting first) */
++ conversations: (id, query = {}, o = {}) => call({ path: `${inbox(id)}/conversations`, query }, o),
++ /** query: after (a message id) → { conversation, messages } */
++ messages: (id, convId, query = {}, o = {}) => call({ path: `${inbox(id)}/conversations/${enc(convId)}/messages`, query }, o),
++ /** A person's answer: the conversation becomes open and Vibe stays quiet in it. */
++ reply: (id, convId, message, o = {}) => call({ method: 'POST', path: `${inbox(id)}/conversations/${enc(convId)}/messages`, json: { message }, ...once }, o),
++ /** status: open | resolved | bot (back to Vibe) */
++ setStatus: (id, convId, status, o = {}) => call({ method: 'PATCH', path: `${inbox(id)}/conversations/${enc(convId)}`, json: { status }, ...once }, o),
++ /** Hand a conversation to one of the inbox's people (their user id), or null to unassign. */
++ assign: (id, convId, assigneeId, o = {}) => call({ method: 'PATCH', path: `${inbox(id)}/conversations/${enc(convId)}`, json: { assignee_id: assigneeId == null ? null : assigneeId }, ...once }, o),
++ /** A visitor's message in the reader's language (kept, so the next reader pays nothing) → { text, lang, cached } */
++ translateMessage: (id, convId, messageId, to, o = {}) => call({ method: 'POST', path: `${inbox(id)}/conversations/${enc(convId)}/messages/${enc(messageId)}/translate`, json: { to }, ...once }, o),
++ /** A reply draft in the visitor's language → { text, lang } */
++ translateDraft: (id, text, to, o = {}) => call({ method: 'POST', path: `${inbox(id)}/translate`, json: { text, to }, ...once }, o),
++ read: (id, convId, o = {}) => call({ method: 'POST', path: `${inbox(id)}/conversations/${enc(convId)}/read`, ...once }, o),
++ /** Verified visitors (owner): action on | rotate | off → { identity_secret } (keep it on your server) */
++ identity: (id, action, o = {}) => call({ method: 'POST', path: `${inbox(id)}/identity`, json: { action }, ...once }, o),
++ /** Saved replies: { replies } */
++ replies: (id, o = {}) => call({ path: `${inbox(id)}/replies` }, o),
++ saveReply: (id, shortcut, body, o = {}) => call({ method: 'POST', path: `${inbox(id)}/replies`, json: { shortcut, body }, ...once }, o),
++ deleteReply: (id, replyId, o = {}) => call({ method: 'DELETE', path: `${inbox(id)}/replies/${enc(replyId)}`, ...once }, o),
++ /** Webhooks (owner): { webhooks }; add → { webhook, secret } (the secret once, for signed JSON) */
++ webhooks: (id, o = {}) => call({ path: `${inbox(id)}/webhooks` }, o),
++ addWebhook: (id, url, o = {}) => call({ method: 'POST', path: `${inbox(id)}/webhooks`, json: { url }, ...once }, o),
++ testWebhook: (id, hookId, o = {}) => call({ method: 'POST', path: `${inbox(id)}/webhooks/${enc(hookId)}/test`, ...once }, o),
++ removeWebhook: (id, hookId, o = {}) => call({ method: 'DELETE', path: `${inbox(id)}/webhooks/${enc(hookId)}`, ...once }, o),
++ /**
++ * Actions (owner): the team's own https endpoints Vibe may ask while it answers (look up an order).
++ * input { name, description, url, params: [{ name, description }] | 'name: what it is' lines } → { action, secret }
++ * (the secret once: each call is signed like an Events delivery, so parseDelivery checks it).
++ */
++ actions: (id, o = {}) => call({ path: `${inbox(id)}/actions` }, o),
++ addAction: (id, input, o = {}) => call({ method: 'POST', path: `${inbox(id)}/actions`, json: input, ...once }, o),
++ /** Call it now with these arguments → { ok, status, error, result } */
++ testAction: (id, actionId, args = {}, o = {}) => call({ method: 'POST', path: `${inbox(id)}/actions/${enc(actionId)}/test`, json: { arguments: args }, ...once }, o),
++ removeAction: (id, actionId, o = {}) => call({ method: 'DELETE', path: `${inbox(id)}/actions/${enc(actionId)}`, ...once }, o),
++ },
++ /** The person's own conversations with sites' chats (they signed in to OpenVibe in the panel). */
++ support: {
++ /** { conversations }: newest first, each with its inbox's name */
++ list: (query = {}, o = {}) => call({ path: '/api/chat/support', query }, o),
++ /** query: after → { conversation, messages } */
++ messages: (convId, query = {}, o = {}) => orNull(call({ path: `/api/chat/support/${enc(convId)}/messages`, query }, o)),
++ /** → { conversation, message }; writing to a resolved one starts a new conversation with that inbox */
++ send: (convId, message, o = {}) => call({ method: 'POST', path: `/api/chat/support/${enc(convId)}/messages`, json: { message }, ...once }, o),
++ },
++ /** Feeds, the owner's side (publishing is createFeedPublisher, with the feed's key). */
++ feeds: {
++ mine: (o = {}) => call({ path: '/api/chat/feeds/mine' }, o),
++ /** { title?, room?, slot?, settings? } → { feed, key }: the key is shown only this once */
++ create: (input = {}, o = {}) => call({ method: 'POST', path: '/api/chat/feeds', json: input, ...once }, o),
++ update: (id, patch, o = {}) => call({ method: 'PATCH', path: feed(id), json: patch, ...once }, o),
++ /** A new key; the old one stops working. → { key } */
++ rotateKey: (id, o = {}) => call({ method: 'POST', path: `${feed(id)}/key`, ...once }, o),
++ remove: (id, o = {}) => call({ method: 'DELETE', path: feed(id), ...once }, o),
++ /** What a viewer sees now (after the feed's delay): { feed, publisher, events }. Anyone may read it. */
++ view: (id, query = {}, o = {}) => orNull(call({ path: feed(id), query, auth: false, credentials: 'omit' }, o)),
++ /** The feeds shown on Live channel slots: { slot: [ids] } or { channel: username } → { feeds } */
++ onLive: (where, o = {}) => call({ path: '/api/chat/feeds', query: where.channel ? { channel: where.channel } : { slot: [].concat(where.slot || []).join(',') }, auth: false, credentials: 'omit' }, o),
++ },
++ };
++}
++
++/**
++ * A support inbox's visitor, for a site that builds its own panel instead of openvibe.chat/widget.js. No account and
++ * no cookies: the visitor is the token visit() returns, which the site keeps (and passes back next time to resume the
++ * conversation). An inbox that names its allowed sites answers only them. Writes are not retried.
++ *
++ * user (an inbox with verified visitors): { id, name?, email?, hash } where hash is HMAC-SHA256 of id with the inbox's
++ * identity secret, in hex, made on your server; Chat then trusts the name and email and gives the person the same
++ * token on every device. A wrong hash is just an anonymous visitor.
++ *
++ * account: the visitor's own OpenVibe sign-in (a Network token), when they have one: their conversation becomes
++ * theirs, in their openvibe.chat Messages and on every device.
++ */
++function createInboxVisitor(client, { inbox, token = null, user = null, account = null, baseUrl } = {}) {
++ if (!/^inb_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(inbox || ''))) throw new TypeError('createInboxVisitor: inbox is an inb_ id');
++ let current = token || null;
++ const call = (opts, o = {}) => client.json({ service: 'chat', baseUrl, auth: false, credentials: 'omit', ...opts, headers: { ...(account ? { Authorization: `Bearer ${account}` } : {}), ...(opts.headers || {}) }, signal: o.signal });
++ const path = (p = '') => `/api/chat/widget/inbox/${enc(inbox)}${p}`;
++ const once = { idempotencyKey: false };
++ return {
++ /** The visitor's token (null before the first visit). */
++ get token() { return current; },
++ /** { inbox }: name, greeting, color, whether Vibe answers */
++ config: (o = {}) => call({ path: path() }, o),
++ /** → { token, inbox, conversation, messages }; a missing or unknown token gets a new one */
++ visit: async (o = {}) => { const d = await call({ method: 'POST', path: path('/visit'), json: { token: current, ...(user ? { user } : {}) }, ...once }, o); current = d.token; return d; },
++ /** extra: page_url, name → { conversation, message }; after a resolved conversation this starts a new one */
++ send: (message, extra = {}, o = {}) => call({ method: 'POST', path: path('/messages'), json: { token: current, message, ...(user ? { user } : {}), ...extra }, ...once }, o),
++ /** query: after (a message id) → { conversation, messages } */
++ messages: (query = {}, o = {}) => call({ path: path('/messages'), query, headers: { 'X-Visitor-Token': current } }, o),
++ /** "Talk to a person" → { conversation } (409 when only Vibe answers in this inbox: config().inbox.people false) */
++ handoff: (o = {}) => call({ method: 'POST', path: path('/handoff'), json: { token: current }, ...once }, o),
++ /** Rate a resolved conversation: 1 (good) or -1 (bad), with a line → { conversation } */
++ rate: (rating, extra = {}, o = {}) => call({ method: 'POST', path: path('/rating'), json: { token: current, rating, ...extra }, ...once }, o),
++ /**
++ * A picture (a Blob, File or Buffer: JPEG, PNG, GIF, WebP or AVIF), with an optional line → { conversation, message,
++ * picture }. Signed-in visitors only (`account`): it goes to OpenVibe.Pics as them; anyone else gets 403
++ * inbox.sign_in_to_attach.
++ */
++ sendPicture: (picture, { caption = '', contentType } = {}, o = {}) => call({
++ method: 'POST', path: path('/picture'), body: picture, ...once,
++ headers: { 'Content-Type': contentType || (picture && picture.type) || 'application/octet-stream', 'X-Visitor-Token': current, ...(caption ? { 'X-Caption': encodeURIComponent(String(caption)) } : {}) },
++ }, o),
++ };
++}
++
++/**
++ * Publish to a feed with its key (ovf_…, from feeds.create or the feed's page on openvibe.chat). Events are Live's
++ * canonical vibe coding events: eventType session.status | prompt | response | thinking | tool.call | file.change |
++ * file.save, with eventId, sessionKey, summary and the type's part ({ prompt: { text } }, { tool: { name } }, …); a short
++ * { eventType, text } works too. Chat scrubs secrets and home folders before anything is stored. A batch whose events
++ * all carry an eventId is retried (Chat stores an eventId once); otherwise it is not.
++ */
++function createFeedPublisher(client, { feed, key, baseUrl } = {}) {
++ if (!/^fed_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(feed || ''))) throw new TypeError('createFeedPublisher: feed is a fed_ id');
++ if (!/^ovf_[A-Za-z0-9_-]{32}$/.test(String(key || ''))) throw new TypeError('createFeedPublisher: key is the feed\'s ovf_ key');
++ const call = (opts, o = {}) => client.json({ service: 'chat', baseUrl, auth: false, credentials: 'omit', headers: { Authorization: `Bearer ${key}` }, ...opts, signal: o.signal });
++ const path = (p) => `/api/chat/feeds/${enc(feed)}${p}`;
++ const hasId = (e) => !!(e && (e.eventId || (e.event && e.event.eventId)));
++ return {
++ /** { sessionKey?, publisher?: { integrationId, integrationLabel, clientName, … } } → { sessionKey, publisher } */
++ hello: (input = {}, o = {}) => call({ method: 'POST', path: path('/hello'), json: input, idempotencyKey: false }, o),
++ /** One event or a list (at most 50) → { events: [{ eventId, delivered }] } */
++ publish: (events, o = {}) => {
++ const list = Array.isArray(events) ? events : [events];
++ return call({ method: 'POST', path: path('/events'), json: { events: list }, ...(list.every(hasId) ? { idempotent: true } : { idempotencyKey: false }) }, o);
++ },
++ /** Claude Code's hook JSON as it is (what an "http" hook posts) → {} */
++ claudeCode: (hook, o = {}) => call({ method: 'POST', path: path('/hooks/claude-code'), json: hook, idempotencyKey: false }, o),
};
}
--module.exports = { createChatClient };
++module.exports = { createChatClient, createInboxVisitor, createFeedPublisher };
},
// ── 19: src/ai.js ───────────────────────────────────────────────────
function (module, exports) {
ddiff --git a/docs/api-surface.json b/docs/api-surface.json
iindex 9655b7a..3b2d6b0 100644
---- a/docs/api-surface.json
++++ b/docs/api-surface.json
@@@ -56,7 +56,9 @@
},
"chat": {
"object": [
-- "createChatClient"
++ "createChatClient",
++ "createFeedPublisher",
++ "createInboxVisitor"
]
},
"commerce": {
@@@ -382,7 +384,9 @@
"createCache": "function"
},
"openvibe-sdk/chat": {
-- "createChatClient": "function"
++ "createChatClient": "function",
++ "createFeedPublisher": "function",
++ "createInboxVisitor": "function"
},
"openvibe-sdk/commerce": {
"CommerceError": "class",
ddiff --git a/esm/chat.mjs b/esm/chat.mjs
iindex 7e0dfc6..2cf71ba 100644
---- a/esm/chat.mjs
++++ b/esm/chat.mjs
@@@ -2,6 +2,6 @@
import cjs from '../src/chat.js';
export const {
-- createChatClient,
++ createChatClient, createInboxVisitor, createFeedPublisher,
} = cjs;
export default cjs;
ddiff --git a/src/chat.js b/src/chat.js
iindex 284d815..c623a4d 100644
---- a/src/chat.js
++++ b/src/chat.js
@@@ -14,6 +14,17 @@
* Every call acts as the token's person (Chat has no service acting-for-someone mode). Writes are never
* retried (Chat does not deduplicate them); reads are. A missing or unreadable room or conversation is
* null from get().
++ *
++ * The rest of Chat as a platform (Chat's docs/platform.md): `vibe` (a person's chats with Vibe, OpenVibe's
++ * assistant), `inboxes` (support inboxes: the team's side) and `feeds` (live activity such as vibe coding: the
++ * owner's side). Two clients need no account at all and answer from any site, without cookies:
++ *
++ * const visitor = createInboxVisitor(client, { inbox: 'inb_…', token: saved }); // build your own chat panel
++ * const { token } = await visitor.visit(); // keep it: it is the visitor
++ * await visitor.send('Where is my order?', { page_url: location.href });
++ *
++ * const feed = createFeedPublisher(client, { feed: 'fed_…', key: feedKey }); // the feed's ovf_ key
++ * await feed.publish({ eventType: 'prompt', text: 'Add a dark mode' }); // or a list, or Live's envelopes
*/
const { isOpenVibeError } = require('./core/errors');
@@@ -26,6 +37,8 @@ function createChatClient(client, defaults = {}) {
const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && (err.status === 404 || err.status === 403)) return null; throw err; });
const room = (slug) => `/api/chat/rooms/${enc(slug)}`;
const conv = (id) => `/api/dm/conversations/${enc(id)}`;
++ const inbox = (id) => `/api/chat/inboxes/${enc(id)}`;
++ const feed = (id) => `/api/chat/feeds/${enc(id)}`;
return {
global: {
@@@ -69,7 +82,158 @@ function createChatClient(client, defaults = {}) {
},
/** Every message you sent that is still visible: { username, total, truncated, messages } (JSON). */
exportMine: (o = {}) => call({ path: '/api/chat/me/export' }, o),
++ /** Vibe, OpenVibe's assistant: the person's chats with it (people only; an API token gets 403). */
++ vibe: {
++ /** { chats, allowance } */
++ list: (o = {}) => call({ path: '/api/chat/vibe' }, o),
++ /** A new chat; Vibe answers in its room (rooms.messages, or the room socket). → { room, message } */
++ start: (message, o = {}) => call({ method: 'POST', path: '/api/chat/vibe', json: { message }, ...once }, o),
++ rename: (slug, name, o = {}) => call({ method: 'PATCH', path: `/api/chat/vibe/${enc(slug)}`, json: { name }, ...once }, o),
++ remove: (slug, o = {}) => call({ method: 'DELETE', path: `/api/chat/vibe/${enc(slug)}`, ...once }, o),
++ },
++ /** Support inboxes, the team's side: Vibe answers visitors first, the inbox's people take over. */
++ inboxes: {
++ /** { inboxes }: those you work in, with waiting and open counts */
++ list: (o = {}) => call({ path: '/api/chat/inboxes' }, o),
++ /** → { inbox, embed: { script, panel } }; embed.script is the one tag a site adds */
++ create: (input, o = {}) => call({ method: 'POST', path: '/api/chat/inboxes', json: input, ...once }, o),
++ get: (id, o = {}) => orNull(call({ path: inbox(id) }, o)),
++ update: (id, patch, o = {}) => call({ method: 'PATCH', path: inbox(id), json: patch, ...once }, o),
++ remove: (id, o = {}) => call({ method: 'DELETE', path: inbox(id), ...once }, o),
++ addMember: (id, username, o = {}) => call({ method: 'POST', path: `${inbox(id)}/members`, json: { username }, ...once }, o),
++ removeMember: (id, userId, o = {}) => call({ method: 'DELETE', path: `${inbox(id)}/members/${enc(userId)}`, ...once }, o),
++ /** query: status = active | bot | waiting | open | resolved → { conversations } (waiting first) */
++ conversations: (id, query = {}, o = {}) => call({ path: `${inbox(id)}/conversations`, query }, o),
++ /** query: after (a message id) → { conversation, messages } */
++ messages: (id, convId, query = {}, o = {}) => call({ path: `${inbox(id)}/conversations/${enc(convId)}/messages`, query }, o),
++ /** A person's answer: the conversation becomes open and Vibe stays quiet in it. */
++ reply: (id, convId, message, o = {}) => call({ method: 'POST', path: `${inbox(id)}/conversations/${enc(convId)}/messages`, json: { message }, ...once }, o),
++ /** status: open | resolved | bot (back to Vibe) */
++ setStatus: (id, convId, status, o = {}) => call({ method: 'PATCH', path: `${inbox(id)}/conversations/${enc(convId)}`, json: { status }, ...once }, o),
++ /** Hand a conversation to one of the inbox's people (their user id), or null to unassign. */
++ assign: (id, convId, assigneeId, o = {}) => call({ method: 'PATCH', path: `${inbox(id)}/conversations/${enc(convId)}`, json: { assignee_id: assigneeId == null ? null : assigneeId }, ...once }, o),
++ /** A visitor's message in the reader's language (kept, so the next reader pays nothing) → { text, lang, cached } */
++ translateMessage: (id, convId, messageId, to, o = {}) => call({ method: 'POST', path: `${inbox(id)}/conversations/${enc(convId)}/messages/${enc(messageId)}/translate`, json: { to }, ...once }, o),
++ /** A reply draft in the visitor's language → { text, lang } */
++ translateDraft: (id, text, to, o = {}) => call({ method: 'POST', path: `${inbox(id)}/translate`, json: { text, to }, ...once }, o),
++ read: (id, convId, o = {}) => call({ method: 'POST', path: `${inbox(id)}/conversations/${enc(convId)}/read`, ...once }, o),
++ /** Verified visitors (owner): action on | rotate | off → { identity_secret } (keep it on your server) */
++ identity: (id, action, o = {}) => call({ method: 'POST', path: `${inbox(id)}/identity`, json: { action }, ...once }, o),
++ /** Saved replies: { replies } */
++ replies: (id, o = {}) => call({ path: `${inbox(id)}/replies` }, o),
++ saveReply: (id, shortcut, body, o = {}) => call({ method: 'POST', path: `${inbox(id)}/replies`, json: { shortcut, body }, ...once }, o),
++ deleteReply: (id, replyId, o = {}) => call({ method: 'DELETE', path: `${inbox(id)}/replies/${enc(replyId)}`, ...once }, o),
++ /** Webhooks (owner): { webhooks }; add → { webhook, secret } (the secret once, for signed JSON) */
++ webhooks: (id, o = {}) => call({ path: `${inbox(id)}/webhooks` }, o),
++ addWebhook: (id, url, o = {}) => call({ method: 'POST', path: `${inbox(id)}/webhooks`, json: { url }, ...once }, o),
++ testWebhook: (id, hookId, o = {}) => call({ method: 'POST', path: `${inbox(id)}/webhooks/${enc(hookId)}/test`, ...once }, o),
++ removeWebhook: (id, hookId, o = {}) => call({ method: 'DELETE', path: `${inbox(id)}/webhooks/${enc(hookId)}`, ...once }, o),
++ /**
++ * Actions (owner): the team's own https endpoints Vibe may ask while it answers (look up an order).
++ * input { name, description, url, params: [{ name, description }] | 'name: what it is' lines } → { action, secret }
++ * (the secret once: each call is signed like an Events delivery, so parseDelivery checks it).
++ */
++ actions: (id, o = {}) => call({ path: `${inbox(id)}/actions` }, o),
++ addAction: (id, input, o = {}) => call({ method: 'POST', path: `${inbox(id)}/actions`, json: input, ...once }, o),
++ /** Call it now with these arguments → { ok, status, error, result } */
++ testAction: (id, actionId, args = {}, o = {}) => call({ method: 'POST', path: `${inbox(id)}/actions/${enc(actionId)}/test`, json: { arguments: args }, ...once }, o),
++ removeAction: (id, actionId, o = {}) => call({ method: 'DELETE', path: `${inbox(id)}/actions/${enc(actionId)}`, ...once }, o),
++ },
++ /** The person's own conversations with sites' chats (they signed in to OpenVibe in the panel). */
++ support: {
++ /** { conversations }: newest first, each with its inbox's name */
++ list: (query = {}, o = {}) => call({ path: '/api/chat/support', query }, o),
++ /** query: after → { conversation, messages } */
++ messages: (convId, query = {}, o = {}) => orNull(call({ path: `/api/chat/support/${enc(convId)}/messages`, query }, o)),
++ /** → { conversation, message }; writing to a resolved one starts a new conversation with that inbox */
++ send: (convId, message, o = {}) => call({ method: 'POST', path: `/api/chat/support/${enc(convId)}/messages`, json: { message }, ...once }, o),
++ },
++ /** Feeds, the owner's side (publishing is createFeedPublisher, with the feed's key). */
++ feeds: {
++ mine: (o = {}) => call({ path: '/api/chat/feeds/mine' }, o),
++ /** { title?, room?, slot?, settings? } → { feed, key }: the key is shown only this once */
++ create: (input = {}, o = {}) => call({ method: 'POST', path: '/api/chat/feeds', json: input, ...once }, o),
++ update: (id, patch, o = {}) => call({ method: 'PATCH', path: feed(id), json: patch, ...once }, o),
++ /** A new key; the old one stops working. → { key } */
++ rotateKey: (id, o = {}) => call({ method: 'POST', path: `${feed(id)}/key`, ...once }, o),
++ remove: (id, o = {}) => call({ method: 'DELETE', path: feed(id), ...once }, o),
++ /** What a viewer sees now (after the feed's delay): { feed, publisher, events }. Anyone may read it. */
++ view: (id, query = {}, o = {}) => orNull(call({ path: feed(id), query, auth: false, credentials: 'omit' }, o)),
++ /** The feeds shown on Live channel slots: { slot: [ids] } or { channel: username } → { feeds } */
++ onLive: (where, o = {}) => call({ path: '/api/chat/feeds', query: where.channel ? { channel: where.channel } : { slot: [].concat(where.slot || []).join(',') }, auth: false, credentials: 'omit' }, o),
++ },
++ };
++}
++
++/**
++ * A support inbox's visitor, for a site that builds its own panel instead of openvibe.chat/widget.js. No account and
++ * no cookies: the visitor is the token visit() returns, which the site keeps (and passes back next time to resume the
++ * conversation). An inbox that names its allowed sites answers only them. Writes are not retried.
++ *
++ * user (an inbox with verified visitors): { id, name?, email?, hash } where hash is HMAC-SHA256 of id with the inbox's
++ * identity secret, in hex, made on your server; Chat then trusts the name and email and gives the person the same
++ * token on every device. A wrong hash is just an anonymous visitor.
++ *
++ * account: the visitor's own OpenVibe sign-in (a Network token), when they have one: their conversation becomes
++ * theirs, in their openvibe.chat Messages and on every device.
++ */
++function createInboxVisitor(client, { inbox, token = null, user = null, account = null, baseUrl } = {}) {
++ if (!/^inb_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(inbox || ''))) throw new TypeError('createInboxVisitor: inbox is an inb_ id');
++ let current = token || null;
++ const call = (opts, o = {}) => client.json({ service: 'chat', baseUrl, auth: false, credentials: 'omit', ...opts, headers: { ...(account ? { Authorization: `Bearer ${account}` } : {}), ...(opts.headers || {}) }, signal: o.signal });
++ const path = (p = '') => `/api/chat/widget/inbox/${enc(inbox)}${p}`;
++ const once = { idempotencyKey: false };
++ return {
++ /** The visitor's token (null before the first visit). */
++ get token() { return current; },
++ /** { inbox }: name, greeting, color, whether Vibe answers */
++ config: (o = {}) => call({ path: path() }, o),
++ /** → { token, inbox, conversation, messages }; a missing or unknown token gets a new one */
++ visit: async (o = {}) => { const d = await call({ method: 'POST', path: path('/visit'), json: { token: current, ...(user ? { user } : {}) }, ...once }, o); current = d.token; return d; },
++ /** extra: page_url, name → { conversation, message }; after a resolved conversation this starts a new one */
++ send: (message, extra = {}, o = {}) => call({ method: 'POST', path: path('/messages'), json: { token: current, message, ...(user ? { user } : {}), ...extra }, ...once }, o),
++ /** query: after (a message id) → { conversation, messages } */
++ messages: (query = {}, o = {}) => call({ path: path('/messages'), query, headers: { 'X-Visitor-Token': current } }, o),
++ /** "Talk to a person" → { conversation } (409 when only Vibe answers in this inbox: config().inbox.people false) */
++ handoff: (o = {}) => call({ method: 'POST', path: path('/handoff'), json: { token: current }, ...once }, o),
++ /** Rate a resolved conversation: 1 (good) or -1 (bad), with a line → { conversation } */
++ rate: (rating, extra = {}, o = {}) => call({ method: 'POST', path: path('/rating'), json: { token: current, rating, ...extra }, ...once }, o),
++ /**
++ * A picture (a Blob, File or Buffer: JPEG, PNG, GIF, WebP or AVIF), with an optional line → { conversation, message,
++ * picture }. Signed-in visitors only (`account`): it goes to OpenVibe.Pics as them; anyone else gets 403
++ * inbox.sign_in_to_attach.
++ */
++ sendPicture: (picture, { caption = '', contentType } = {}, o = {}) => call({
++ method: 'POST', path: path('/picture'), body: picture, ...once,
++ headers: { 'Content-Type': contentType || (picture && picture.type) || 'application/octet-stream', 'X-Visitor-Token': current, ...(caption ? { 'X-Caption': encodeURIComponent(String(caption)) } : {}) },
++ }, o),
++ };
++}
++
++/**
++ * Publish to a feed with its key (ovf_…, from feeds.create or the feed's page on openvibe.chat). Events are Live's
++ * canonical vibe coding events: eventType session.status | prompt | response | thinking | tool.call | file.change |
++ * file.save, with eventId, sessionKey, summary and the type's part ({ prompt: { text } }, { tool: { name } }, …); a short
++ * { eventType, text } works too. Chat scrubs secrets and home folders before anything is stored. A batch whose events
++ * all carry an eventId is retried (Chat stores an eventId once); otherwise it is not.
++ */
++function createFeedPublisher(client, { feed, key, baseUrl } = {}) {
++ if (!/^fed_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(feed || ''))) throw new TypeError('createFeedPublisher: feed is a fed_ id');
++ if (!/^ovf_[A-Za-z0-9_-]{32}$/.test(String(key || ''))) throw new TypeError('createFeedPublisher: key is the feed\'s ovf_ key');
++ const call = (opts, o = {}) => client.json({ service: 'chat', baseUrl, auth: false, credentials: 'omit', headers: { Authorization: `Bearer ${key}` }, ...opts, signal: o.signal });
++ const path = (p) => `/api/chat/feeds/${enc(feed)}${p}`;
++ const hasId = (e) => !!(e && (e.eventId || (e.event && e.event.eventId)));
++ return {
++ /** { sessionKey?, publisher?: { integrationId, integrationLabel, clientName, … } } → { sessionKey, publisher } */
++ hello: (input = {}, o = {}) => call({ method: 'POST', path: path('/hello'), json: input, idempotencyKey: false }, o),
++ /** One event or a list (at most 50) → { events: [{ eventId, delivered }] } */
++ publish: (events, o = {}) => {
++ const list = Array.isArray(events) ? events : [events];
++ return call({ method: 'POST', path: path('/events'), json: { events: list }, ...(list.every(hasId) ? { idempotent: true } : { idempotencyKey: false }) }, o);
++ },
++ /** Claude Code's hook JSON as it is (what an "http" hook posts) → {} */
++ claudeCode: (hook, o = {}) => call({ method: 'POST', path: path('/hooks/claude-code'), json: hook, idempotencyKey: false }, o),
};
}
--module.exports = { createChatClient };
++module.exports = { createChatClient, createInboxVisitor, createFeedPublisher };
ddiff --git a/test/chat.test.js b/test/chat.test.js
iindex 24856b9..63ce21b 100644
---- a/test/chat.test.js
++++ b/test/chat.test.js
@@@ -1,9 +1,13 @@
'use strict';
--/** Chat: paths, bodies and methods for global, rooms and DMs; writes never retried; a missing room is null. */
++/**
++ * Chat: paths, bodies and methods for global, rooms and DMs; writes never retried; a missing room is null. Inboxes,
++ * feeds and Vibe go to their paths; the inbox visitor and the feed publisher send no account token and no cookies, the
++ * visitor keeps its token, the publisher sends the feed's key and retries only batches whose events all have an id.
++ */
const assert = require('node:assert/strict');
const { stubServer, send, run } = require('./helpers');
const { createClient } = require('../src/core');
--const { createChatClient } = require('../src/chat');
++const { createChatClient, createInboxVisitor, createFeedPublisher } = require('../src/chat');
async function chatStub() {
let busy = 0;
@@@ -48,4 +52,83 @@ run([
assert.equal(await chat.rooms.get('private-one'), null);
await srv.close();
}],
++ ['inboxes, feeds and Vibe go to their paths', async () => {
++ const srv = await chatStub();
++ const chat = createChatClient(createClient({ baseUrls: { chat: srv.url }, token: 'user-jwt' }));
++ await chat.inboxes.create({ name: 'Acme' });
++ await chat.inboxes.conversations('inb_X', { status: 'waiting' });
++ await chat.inboxes.reply('inb_X', 'cnv_Y', 'On its way');
++ await chat.inboxes.setStatus('inb_X', 'cnv_Y', 'resolved');
++ await chat.feeds.create({ title: 'Building', slot: 77 });
++ await chat.feeds.rotateKey('fed_Z');
++ await chat.feeds.view('fed_Z', { limit: 5 });
++ await chat.feeds.onLive({ slot: [77, 78] });
++ await chat.vibe.start('hello');
++ await chat.inboxes.saveReply('inb_X', 'refund', 'Five days.');
++ await chat.inboxes.addWebhook('inb_X', 'https://hooks.slack.com/services/x');
++ await chat.inboxes.assign('inb_X', 'cnv_Y', 7);
++ await chat.inboxes.addAction('inb_X', { name: 'look_up_order', description: 'Finds an order.', url: 'https://shop.example/vibe', params: 'order_id: the number' });
++ await chat.inboxes.testAction('inb_X', 3, { order_id: '42' });
++ const got = srv.requests.map((r) => [r.method, r.url, r.body && String(r.body).length ? JSON.parse(String(r.body)) : null]);
++ assert.deepEqual(got, [
++ ['POST', '/api/chat/inboxes', { name: 'Acme' }],
++ ['GET', '/api/chat/inboxes/inb_X/conversations?status=waiting', null],
++ ['POST', '/api/chat/inboxes/inb_X/conversations/cnv_Y/messages', { message: 'On its way' }],
++ ['PATCH', '/api/chat/inboxes/inb_X/conversations/cnv_Y', { status: 'resolved' }],
++ ['POST', '/api/chat/feeds', { title: 'Building', slot: 77 }],
++ ['POST', '/api/chat/feeds/fed_Z/key', null],
++ ['GET', '/api/chat/feeds/fed_Z?limit=5', null],
++ ['GET', '/api/chat/feeds?slot=77%2C78', null],
++ ['POST', '/api/chat/vibe', { message: 'hello' }],
++ ['POST', '/api/chat/inboxes/inb_X/replies', { shortcut: 'refund', body: 'Five days.' }],
++ ['POST', '/api/chat/inboxes/inb_X/webhooks', { url: 'https://hooks.slack.com/services/x' }],
++ ['PATCH', '/api/chat/inboxes/inb_X/conversations/cnv_Y', { assignee_id: 7 }],
++ ['POST', '/api/chat/inboxes/inb_X/actions', { name: 'look_up_order', description: 'Finds an order.', url: 'https://shop.example/vibe', params: 'order_id: the number' }],
++ ['POST', '/api/chat/inboxes/inb_X/actions/3/test', { arguments: { order_id: '42' } }],
++ ]);
++ assert.equal(srv.requests[6].headers.authorization, undefined, 'a feed is read without the person\'s token');
++ await srv.close();
++ }],
++ ['an inbox visitor needs no account and keeps its token; a feed publisher sends the key', async () => {
++ const INBOX = `inb_${'A'.repeat(26)}`; const FEED = `fed_${'B'.repeat(26)}`; const KEY = `ovf_${'k'.repeat(32)}`;
++ let fails = 1;
++ const srv = await stubServer((req, res) => {
++ if (req.url.endsWith('/visit')) return send(res, 200, { token: 'T'.repeat(32), conversation: null, messages: [] });
++ if (req.url.endsWith('/events') && fails-- > 0) return send(res, 503, { error: 'busy' });
++ return send(res, 200, { ok: true });
++ });
++ const client = createClient({ baseUrls: { chat: srv.url }, token: 'someone-else', retries: 2 });
++ const v = createInboxVisitor(client, { inbox: INBOX });
++ await v.visit();
++ assert.equal(v.token, 'T'.repeat(32));
++ await v.send('hi', { page_url: 'https://shop.example/' });
++ await v.messages({ after: 3 });
++ await v.rate(1, { comment: 'Quick!' });
++ assert.deepEqual(JSON.parse(String(srv.requests[3].body)), { token: 'T'.repeat(32), rating: 1, comment: 'Quick!' });
++ const sent = srv.requests.slice(0, 3);
++ assert.ok(sent.every((r) => r.headers.authorization === undefined), 'no account token');
++ assert.deepEqual(JSON.parse(String(sent[1].body)), { token: 'T'.repeat(32), message: 'hi', page_url: 'https://shop.example/' });
++ assert.equal(sent[2].headers['x-visitor-token'], 'T'.repeat(32));
++ const signed = createInboxVisitor(client, { inbox: INBOX, user: { id: 'cust-42', hash: 'a'.repeat(64) } });
++ await signed.visit();
++ assert.deepEqual(JSON.parse(String(srv.requests[srv.requests.length - 1].body)).user, { id: 'cust-42', hash: 'a'.repeat(64) }, 'a verified visitor sends who they are');
++ const mine = createInboxVisitor(client, { inbox: INBOX, account: 'net-jwt' });
++ await mine.visit();
++ assert.equal(srv.requests[srv.requests.length - 1].headers.authorization, 'Bearer net-jwt', 'a signed-in visitor sends their own sign-in');
++ await mine.sendPicture(new Blob([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], { type: 'image/png' }), { caption: 'The crack, here' });
++ const pic = srv.requests[srv.requests.length - 1];
++ assert.deepEqual([pic.url, pic.headers['content-type'], pic.headers['x-visitor-token'], decodeURIComponent(pic.headers['x-caption']), pic.headers.authorization, pic.body.length],
++ [`/api/chat/widget/inbox/${INBOX}/picture`, 'image/png', 'T'.repeat(32), 'The crack, here', 'Bearer net-jwt', 4], 'a picture goes as itself, with the token in a header');
++ const f = createFeedPublisher(client, { feed: FEED, key: KEY });
++ await f.publish([{ eventId: 'e1', eventType: 'prompt', text: 'x' }]);
++ const pubs = srv.requests.filter((r) => r.url.endsWith('/events'));
++ assert.equal(pubs.length, 2, 'a batch whose events all have ids is retried');
++ assert.equal(pubs[0].headers.authorization, `Bearer ${KEY}`);
++ fails = 1;
++ await assert.rejects(f.publish({ eventType: 'prompt', text: 'no id' }), (e) => e.status === 503);
++ assert.equal(srv.requests.filter((r) => r.url.endsWith('/events')).length, 3, 'one without ids is not');
++ assert.throws(() => createFeedPublisher(client, { feed: FEED, key: 'hbt_x' }), TypeError);
++ assert.throws(() => createInboxVisitor(client, { inbox: 'nope' }), TypeError);
++ await srv.close();
++ }],
]);
ddiff --git a/types/chat.d.ts b/types/chat.d.ts
iindex e81ec1f..4ccd680 100644
---- a/types/chat.d.ts
++++ b/types/chat.d.ts
@@@ -36,5 +36,99 @@ export interface ChatClient {
unblock(userId: number | string, opts?: Opts): Promise<Json>;
};
exportMine(opts?: Opts): Promise<{ username: string; exported_at: string; total: number; truncated: boolean; messages: Json[] }>;
++ vibe: {
++ list(opts?: Opts): Promise<{ chats: Json[]; allowance?: Json }>;
++ start(message: string, opts?: Opts): Promise<{ room: Json; message: Json }>;
++ rename(slug: string, name: string, opts?: Opts): Promise<Json>;
++ remove(slug: string, opts?: Opts): Promise<Json>;
++ };
++ inboxes: {
++ list(opts?: Opts): Promise<{ inboxes: Json[] }>;
++ create(input: InboxInput & { name: string }, opts?: Opts): Promise<{ inbox: Json; embed: { script: string; panel: string } }>;
++ get(id: string, opts?: Opts): Promise<{ inbox: Json; role: 'owner' | 'agent'; members: Json[]; embed: { script: string; panel: string } } | null>;
++ update(id: string, patch: InboxInput, opts?: Opts): Promise<Json>;
++ remove(id: string, opts?: Opts): Promise<Json>;
++ addMember(id: string, username: string, opts?: Opts): Promise<Json>;
++ removeMember(id: string, userId: number | string, opts?: Opts): Promise<Json>;
++ conversations(id: string, query?: { status?: 'active' | ConversationStatus }, opts?: Opts): Promise<{ conversations: Json[] }>;
++ messages(id: string, convId: string, query?: { after?: number }, opts?: Opts): Promise<{ conversation: Json; messages: InboxMessage[] }>;
++ reply(id: string, convId: string, message: string, opts?: Opts): Promise<{ message: InboxMessage }>;
++ setStatus(id: string, convId: string, status: 'open' | 'resolved' | 'bot', opts?: Opts): Promise<{ conversation: Json }>;
++ assign(id: string, convId: string, assigneeId: number | null, opts?: Opts): Promise<{ conversation: Json }>;
++ translateMessage(id: string, convId: string, messageId: number | string, to: string, opts?: Opts): Promise<{ text: string; lang: string; cached: boolean }>;
++ translateDraft(id: string, text: string, to: string, opts?: Opts): Promise<{ text: string; lang: string }>;
++ read(id: string, convId: string, opts?: Opts): Promise<Json>;
++ identity(id: string, action: 'on' | 'rotate' | 'off', opts?: Opts): Promise<{ identity_secret: string | null }>;
++ replies(id: string, opts?: Opts): Promise<{ replies: { id: number; shortcut: string; body: string }[] }>;
++ saveReply(id: string, shortcut: string, body: string, opts?: Opts): Promise<{ replies: { id: number; shortcut: string; body: string }[] }>;
++ deleteReply(id: string, replyId: number | string, opts?: Opts): Promise<{ replies: { id: number; shortcut: string; body: string }[] }>;
++ webhooks(id: string, opts?: Opts): Promise<{ webhooks: Json[] }>;
++ addWebhook(id: string, url: string, opts?: Opts): Promise<{ webhook: Json; secret: string | null }>;
++ testWebhook(id: string, hookId: number | string, opts?: Opts): Promise<{ ok: boolean; status: number | null; error: string | null }>;
++ removeWebhook(id: string, hookId: number | string, opts?: Opts): Promise<void | Json>;
++ actions(id: string, opts?: Opts): Promise<{ actions: InboxAction[] }>;
++ addAction(id: string, input: InboxActionInput, opts?: Opts): Promise<{ action: InboxAction; secret: string }>;
++ testAction(id: string, actionId: number | string, args?: Record<string, string>, opts?: Opts): Promise<{ ok: boolean; status: number | null; error: string | null; result: string | null }>;
++ removeAction(id: string, actionId: number | string, opts?: Opts): Promise<void | Json>;
++ };
++ support: {
++ list(query?: { limit?: number }, opts?: Opts): Promise<{ conversations: Json[] }>;
++ messages(convId: string, query?: { after?: number }, opts?: Opts): Promise<{ conversation: Json; messages: InboxMessage[] } | null>;
++ send(convId: string, message: string, opts?: Opts): Promise<{ conversation: Json; message: InboxMessage }>;
++ };
++ feeds: {
++ mine(opts?: Opts): Promise<{ feeds: Json[] }>;
++ create(input?: FeedInput, opts?: Opts): Promise<{ feed: Json; key: string }>;
++ update(id: string, patch: FeedInput, opts?: Opts): Promise<{ feed: Json }>;
++ rotateKey(id: string, opts?: Opts): Promise<{ key: string }>;
++ remove(id: string, opts?: Opts): Promise<void | Json>;
++ view(id: string, query?: { limit?: number }, opts?: Opts): Promise<{ feed: Json; publisher: Json | null; events: FeedEvent[] } | null>;
++ onLive(where: { slot: number | number[] } | { channel: string }, opts?: Opts): Promise<{ feeds: Json[] }>;
++ };
++}
++export type ConversationStatus = 'bot' | 'waiting' | 'open' | 'resolved';
++export interface InboxInput {
++ name?: string; greeting?: string; color?: string; ai_enabled?: boolean; ai_instructions?: string; knowledge_urls?: string[] | string; allowed_origins?: string[] | string;
++ /** false: a site assistant, only Vibe answers */
++ handoff?: boolean;
++ /** When the inbox's people answer; null: always */
++ hours?: { tz: string; days: number[]; start: string; end: string } | null;
++ /** round_robin: a visitor waiting goes to the next of the people */
++ route?: 'none' | 'round_robin';
++}
++export interface InboxActionInput { name: string; description: string; url: string; params?: { name: string; description?: string }[] | string }
++export interface InboxAction { id: number; name: string; description: string; url: string; params: { name: string; description: string }[]; last_status: number | null; last_error: string | null; last_at: string | null }
++export interface InboxMessage { id: number; author: 'visitor' | 'agent' | 'ai' | 'system'; author_name?: string | null; body: string; created_at: string; /** a visitor's message written signed in: its Pics links show as pictures */ account?: true }
++export interface FeedInput { title?: string; room?: string | number | null; slot?: number | null; settings?: Record<string, number | string | boolean> }
++export type FeedEventType = 'session.status' | 'prompt' | 'response' | 'thinking' | 'tool.call' | 'file.change' | 'file.save';
++export interface FeedEvent {
++ eventType: FeedEventType;
++ eventId?: string;
++ sessionKey?: string;
++ sequence?: number;
++ occurredAt?: string;
++ summary?: string;
++ text?: string;
++ prompt?: { text: string }; response?: { text: string }; thinking?: { text: string };
++ tool?: { name: string; phase?: string; argumentsPreview?: string | null; resultPreview?: string | null };
++ file?: { name?: string; relativePath?: string | null; operation?: string; changeCount?: number; snippet?: string | null };
++ publisher?: Json; metadata?: Json; source?: Json;
++}
++export interface InboxVisitor {
++ readonly token: string | null;
++ config(opts?: Opts): Promise<{ inbox: { id: string; name: string; greeting: string; color: string; ai: boolean; people: boolean } }>;
++ visit(opts?: Opts): Promise<{ token: string; verified?: boolean; account?: { name: string } | null; inbox: Json; conversation: Json | null; messages: InboxMessage[] }>;
++ send(message: string, extra?: { page_url?: string; name?: string }, opts?: Opts): Promise<{ conversation: Json; message: InboxMessage }>;
++ messages(query?: { after?: number }, opts?: Opts): Promise<{ conversation: Json | null; messages: InboxMessage[] }>;
++ handoff(opts?: Opts): Promise<{ conversation: Json }>;
++ rate(rating: 1 | -1, extra?: { comment?: string; conversation?: string }, opts?: Opts): Promise<{ conversation: Json }>;
++ sendPicture(picture: Blob | ArrayBuffer | Uint8Array, extra?: { caption?: string; contentType?: string }, opts?: Opts): Promise<{ conversation: Json; message: InboxMessage; picture: { slug: string; direct: string; page: string } }>;
++}
++export interface FeedPublisher {
++ hello(input?: { sessionKey?: string; publisher?: Json }, opts?: Opts): Promise<{ sessionKey: string; publisher: Json }>;
++ publish(events: FeedEvent | FeedEvent[], opts?: Opts): Promise<{ events: { eventId: string; delivered: boolean }[] }>;
++ claudeCode(hook: Json, opts?: Opts): Promise<Json>;
}
export function createChatClient(client: OpenVibeClient, defaults?: { baseUrl?: string }): ChatClient;
++export function createInboxVisitor(client: OpenVibeClient, options: { inbox: string; token?: string | null; user?: { id: string; name?: string; email?: string; hash: string } | null; account?: string | null; baseUrl?: string }): InboxVisitor;
++export function createFeedPublisher(client: OpenVibeClient, options: { feed: string; key: string; baseUrl?: string }): FeedPublisher;