How it fits together
Sales Core AI is an AI salesperson that talks to your customers, qualifies them, recommends real items with exact prices, takes contacts, books and orders. You integrate it in two places.
Sales Core AI agent
- validates the input against your schema
- checks business rules and handoffs
- records every call and its result
- Website chat — a script tag on your pages. Nothing to build: the agent, knowledge and catalog are set up in the dashboard.
- Connectors — when the catalog, orders or bookings live in your own system, expose them as tools over HTTPS. The agent calls them in the middle of a conversation; you send order status changes back.
The model decides, the platform controls
The AI chooses what to say and which tool to call. The platform fills in who the customer is and which conversation it is, validates the input, blocks actions after a handoff to a person and records the whole exchange. Your system never receives identifiers made up by the model.Quick start: chat on your website
The chat is a small loader that draws a launcher button and opens the conversation in a secure frame. It works with any site builder or framework.
- 1
Create an agent
Sign up, pick your business type and let the setup assistant read your website. Test it in the agent’s test chat.
- 2
Style the chat
In Agent → Website chat choose colors, light or dark theme, position, greeting, launcher label and the proactive invitation.
- 3
Paste the line
Copy the snippet from the same page and put it before
</body>on every page where the chat should appear. - 4
Allow your domains
List the hosts that may show the chat, e.g.
shop.example.comor*.example.com. An empty list means any site.
<script src="https://app.salescoreai.com/widget.js"
data-agent-id="YOUR_AGENT_ID" async></script>Language
The chat speaks your site’s language: data-lang on the script, otherwise the page’s <html lang> — and it follows when your site changes it. The built-in texts (input hint, buttons, errors, “the chat has ended”…) come in that language, and the agent uses it for its first words until the customer writes; after that it always replies in the customer’s language. Built-in texts exist in English, Russian, Ukrainian, Greek, Spanish and German; other languages get English texts, while the agent still follows the language.
<!-- Fixed language -->
<script src="https://app.salescoreai.com/widget.js" data-agent-id="YOUR_AGENT_ID" data-lang="ru" async></script>
<!-- Or nothing at all: the chat follows <html lang="…"> of the page -->
<html lang="de">JavaScript API
When the loader is ready it exposes window.SalesCoreAI and fires an ai-sales:ready event on window with the same object in event.detail.
// Open, close or toggle the chat from your own button
SalesCoreAI.open();
SalesCoreAI.close();
SalesCoreAI.toggle();
// A signed-in visitor: the agent will not ask for what it already knows
SalesCoreAI.identify({ name: user.name, email: user.email, phone: user.phone });| Method | What it does |
|---|---|
open() | Opens the chat. The conversation frame is created on the first open, so the chat costs your page almost nothing until then. |
close() | Closes the chat window. The conversation is kept. |
toggle() | Opens or closes. |
setLanguage(code) | The site switched language without a reload ('ru', 'en-GB'…). The chat’s texts change at once and the agent gets the new language. Not needed if you change <html lang> — the chat follows it. |
identify({ name, email, phone }) | Passes what your site knows about a signed-in visitor. Any field may be omitted; each is cut to 200 characters. The agent uses them instead of asking again, and they arrive in context.customer of connector calls. |
// Your language switcher changed the site's language without a reload
SalesCoreAI.setLanguage('es');Counting results in your analytics
When the agent books, places an order or saves the visitor’s contact, the chat fires a salescoreai:conversion event on window with event.detail.kind — booking, order or contact. Only the kind is sent, no personal data, and only for what happens on this visit. Send it to your analytics or ad platform — with the visitor’s consent where the law requires it.
window.addEventListener('salescoreai:conversion', (event) => {
// 'booking' | 'order' | 'contact'
if (event.detail.kind === 'booking') {
gtag('event', 'conversion', { send_to: 'AW-XXXXXXXXX/XXXXXXXXXXXX' });
}
});Calling it before the script has loaded
The script is loaded with async, so your code may run first. Wait for the event:
function withChat(run) {
if (window.SalesCoreAI) run(window.SalesCoreAI);
else window.addEventListener('ai-sales:ready', (event) => run(event.detail), { once: true });
}
document.querySelector('#talk-to-sales').addEventListener('click', () => {
withChat((chat) => chat.open());
});window.AISales — it is the same object and keeps working.How the chat behaves
| Topic | Behaviour |
|---|---|
| Isolation | The launcher lives in a Shadow DOM, so your CSS cannot break it. The conversation runs in an iframe on our origin, so your scripts cannot read it. |
| Returning visitors | The visitor’s conversation continues after a reload or on another page of the same site. |
| Mobile | Below 480 px the chat opens full screen. Esc closes it on desktop. |
| Unread badge | Replies that arrive while the chat is closed (for example from a colleague who took over) show a counter on the launcher. |
| When it hides | The launcher does not appear if the agent is paused, the chat is switched off, the balance is below zero, or the page’s host is not in the allowed domains. The browser console says which. |
| Colleagues | When a person takes over, the visitor sees who joined; the colleague’s replies appear in the same chat. |
Connectors
A connector gives the agent the tools of another system: search your catalog, place an order there, check its status, book a slot. Protocol ai-sales-connector/1 is two HTTPS routes on your side and one webhook on ours.
| Direction | Request | Purpose |
|---|---|---|
| Platform → you | GET {baseUrl}/ai-sales/manifest | Which tools you offer, their roles and input schemas. |
| Platform → you | POST {baseUrl}/ai-sales/tools/{name} | The agent calls one tool during a conversation. |
| You → platform | POST https://api.salescoreai.com/api/webhooks/connectors/{connectorId} | An order changed status (paid, shipped, cancelled…). |
| You → platform | GET https://api.salescoreai.com/api/connectors/{connectorId}/account | Optional: balance and usage to show in your admin. |
Every request in both directions is signed with a shared secret. A connector belongs to an organization; one organization may connect several systems.
A minimal server
No SDK is required — any language that can compute HMAC-SHA256 works. For Node.js we also share @ai-sales/connector-sdk and a reference shop connector with teams that integrate: ask us.
import express from 'express';
import { verify } from './verify.js';
const app = express();
const secret = process.env.SALES_CORE_SECRET;
// The signature covers the exact bytes: keep the raw body.
app.use(express.text({ type: 'application/json' }));
const manifest = { protocol: 'ai-sales-connector/1', name: 'My shop', tools: [/* … */] };
const tools = {
async search_mats(input, context) {
const items = await db.findSets(input);
return { success: true, data: { items } };
}
};
app.get('/ai-sales/manifest', (req, res) => {
if (!verify(secret, req.headers, '')) return res.status(401).end();
res.json(manifest);
});
app.post('/ai-sales/tools/:name', async (req, res) => {
if (!verify(secret, req.headers, req.body)) return res.status(401).end();
const { input, context } = JSON.parse(req.body);
const tool = tools[req.params.name];
if (!tool) return res.json({ success: false, error: { code: 'UNKNOWN_TOOL', message: 'No such tool' } });
try {
res.json(await tool(input ?? {}, context));
} catch (error) {
console.error(error); // never send internals back: the model reads the message
res.json({ success: false, error: { code: 'INTERNAL_ERROR', message: 'Could not process this request. Try again or hand over to a colleague.' } });
}
});
app.listen(8080);Manifest
The platform reads the manifest when you press Refresh tools in the dashboard. The request is signed, with an empty body.
{
"protocol": "ai-sales-connector/1",
"name": "MatFit shop",
"description": "Made-to-fit car mats: catalog with stock, orders and their status.",
"testMode": true,
"tools": [
{
"name": "search_mats",
"role": "catalog.search",
"description": "Find mat sets that fit a car. Returns sets with exact price and stock.",
"inputSchema": {
"type": "object",
"properties": {
"make": { "type": "string" },
"model": { "type": "string" },
"year": { "type": "integer", "minimum": 1980 }
},
"required": ["make", "model"]
}
},
{
"name": "create_order",
"role": "order.create",
"requiresContact": true,
"description": "Place an order for chosen sets with a delivery address.",
"inputSchema": {
"type": "object",
"properties": {
"items": { "type": "array", "items": { "type": "object" } },
"deliveryAddress": { "type": "string" }
},
"required": ["items"]
}
},
{
"name": "get_order_status",
"role": "order.status",
"description": "Status of the customer's orders.",
"inputSchema": { "type": "object", "properties": {} }
}
]
}| Field | Meaning |
|---|---|
protocol | Always ai-sales-connector/1. |
name, description | Shown to the business owner in the dashboard. |
testMode | Your handlers honour context.test. See Test mode. |
tools[].name | ^[a-z][a-z0-9_]{0,40}$. The agent sees it as <connector>__<name>. |
tools[].role | What the tool means to the sales funnel. See Roles. |
tools[].description | Read by the model: say when to use the tool, what to pass and what comes back. |
tools[].inputSchema | JSON Schema of the input object. The platform validates the model’s input against it before calling you. |
tools[].requiresContact | Callable only once the customer’s phone or email is known. |
Write descriptions for the model
The description is the tool’s instruction. “Find mat sets that fit a car. Pass make, model and year; optionally material. Returns sets with exact price and stock.” works far better than “Search”.Roles
Semantics come from the role, not the tool’s name. A role can replace a built-in tool and moves the customer along the funnel when the call succeeds.
| Role | Meaning | Effect in the platform |
|---|---|---|
catalog.search | Search items | Replaces the built-in catalog search. Non-empty data.items counts as items presented to the customer. |
catalog.get | One item | Replaces the built-in item lookup. |
catalog.images | Pictures of an item | Pictures of an item in the options the customer chose (e.g. rendered in their colours): data.images with https URLs appears under the agent’s reply. Items from search may also carry images; the agent shows them when asked. |
order.create | Place an order | Replaces the built-in order request. Success moves the customer to “order requested” and the order appears in Orders with your id. |
order.status | The customer’s orders | Read-only. |
order.update | Change the customer’s order | An action (idempotency key, blocked after a handoff); does not change the stage. |
order.cancel | Cancel the customer’s order | An action (idempotency key, blocked after a handoff); does not change the stage. |
booking.availability | Free slots | Replaces the built-in calendar lookup. |
booking.create | Book a slot | Replaces built-in booking. Success confirms the appointment in the funnel. |
booking.cancel | Cancel a booking | Replaces built-in cancellation. |
lead.create | Send the lead to your CRM | An action; does not change the stage. |
custom | Anything else | No special meaning. |
Actions
Roles ending in.create, .cancel or .update are actions: they get an idempotencyKey and are blocked once the conversation is handed to a person.Tool calls
The model supplies only input. Everything in context is filled in by the platform, and the request is signed, so you can trust it.
POST {baseUrl}/ai-sales/tools/search_mats
Content-Type: application/json
X-AI-Sales-Timestamp: 1790331159
X-AI-Sales-Signature: sha256=5d41402abc4b2a76b9719d911017c592…
{
"input": { "make": "Kia", "model": "Rio", "year": 2019 },
"context": {
"organizationId": "org_…",
"conversationId": "conv_…",
"customerId": "cust_…",
"previousCustomerIds": ["cust_…"],
"leadId": "lead_…",
"customer": { "name": "Anna", "email": null, "phone": "+30…" },
"channel": "WEB_CHAT",
"timezone": "Europe/Athens",
"idempotencyKey": "conv_…:create_order:3f2a…",
"test": false
},
"requestId": "req_…"
}| Context field | Meaning |
|---|---|
organizationId | The business the connector belongs to. |
conversationId | The conversation the call is made from. |
customerId | The customer; stable across channels once records are merged. |
previousCustomerIds | Earlier ids of the same person. Present only after a merge. |
leadId | The customer’s lead, when there is one. |
customer | name, email, phone the agent has learned so far (null when unknown). |
channel | WEB_CHAT, WHATSAPP or TELEGRAM. |
timezone | The organization’s IANA timezone, e.g. Europe/Athens. |
idempotencyKey | Actions only. A repeat call with the same key must return the same result without repeating the action. |
test | true in the owner’s test runs. See Test mode. |
One person, one customer
Someone may write in Telegram first and on the website later. When the email or phone matches, the platform merges the records: customerId becomes the older id, and the others arrive in previousCustomerIds. Look orders up by all of them, or an order placed in another channel will seem to vanish.
const ids = [context.customerId, ...(context.previousCustomerIds ?? [])];
const orders = await db.orders.findMany({ where: { salesCoreCustomerId: { in: ids } } });Prefill checkout
Store context.customer with the order or cart and prefill your payment form behind checkoutUrl, so the customer does not type their name and email again.
Responses
Always answer HTTP 200 with success and either data or error. Any other status means an infrastructure failure.
{
"success": true,
"data": {
"items": [
{ "id": "MF-RIO-17-EVA", "name": "Kia Rio 2017–2023 · EVA · Full interior",
"price": 89, "currency": "EUR", "material": "EVA", "inStock": true }
]
}
}The model reads error.message: write it so it knows what to do next — offer an alternative, ask for a missing detail, or hand over to a colleague. Never put stack traces or internal details there.
| Role | Shape of data |
|---|---|
catalog.search | { items: [{ id, name, price, currency, … }], closestItems?: [...] } — closestItems are fitting alternatives when nothing matches exactly. Other simple fields are shown to the agent as the item’s attributes. |
order.create | { orderId, status, items: [{ id, name, price, currency, quantity }], total, currency, deliveryAddress?, checkoutUrl? } |
order.status | { orders: [{ orderId, status, … }] } |
{
"success": true,
"data": {
"orderId": "A-1042",
"status": "NEW",
"items": [{ "id": "MF-RIO-17-EVA", "name": "Kia Rio · EVA", "price": 89, "currency": "EUR", "quantity": 1 }],
"total": 89,
"currency": "EUR",
"deliveryAddress": "12 Ermou St, Athens",
"checkoutUrl": "https://shop.example.com/pay/A-1042"
}
}Payment button
If order.create returns an http(s) checkoutUrl, the customer gets a “Go to payment · total” button under the message, in their language, and the raw link is removed from the text. In Telegram it is an inline button (public https only), in WhatsApp a link line after the text.
Limits
| Limit | Value |
|---|---|
| Timeout | 15 seconds |
| Response size | 256 KB |
| What the model reads | ≈ 8,000 characters of JSON per result. Longer results are shortened for the model — nested lists first, then long strings, items last. The call log keeps the full response. |
| Errors | Network errors and non-200 answers reach the model as CONNECTOR_UNAVAILABLE, without details. |
Keep data compact
The model rereads every tool result on each following step of the turn, so size multiplies. Return a short summary per item in search and leave full variant lists and long descriptions tocatalog.get.Signing requests
The platform gives you a secret when the connector is created; it is shown once. Every request in both directions carries two headers.
X-AI-Sales-Timestamp: 1790331159
X-AI-Sales-Signature: sha256=<hex HMAC-SHA256(secret, timestamp + "." + rawBody)>- The timestamp is Unix time in seconds. Reject requests more than 5 minutes off your clock.
- Sign the exact raw body as sent, before any JSON parsing. For
GETthe body is an empty string. - Compare signatures in constant time.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(secret, headers, rawBody) {
const timestamp = headers['x-ai-sales-timestamp'] ?? '';
const signature = headers['x-ai-sales-signature'] ?? '';
if (!/^\d+$/.test(timestamp) || !signature.startsWith('sha256=')) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; // 5 minutes
const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest();
const given = Buffer.from(signature.slice(7), 'hex');
return given.length === expected.length && timingSafeEqual(given, expected);
}Order events
Order status changes come to the platform only through this signed webhook. PAID and FULFILLED close the sale, and commission is counted from it.
{
"type": "order.updated",
"orderId": "A-1042",
"orderNumber": "FE-009774",
"status": "PAID",
"note": "Paid by card"
}| Field | Meaning |
|---|---|
type | Always order.updated. |
orderId | The id your order.create returned. |
status | CONFIRMED, CANCELLED, PAID, SHIPPED or FULFILLED. |
orderNumber | Optional. The number the customer sees in emails or receipts, when it differs from orderId. Shown in the orders list and to the agent, so the customer can name it in the chat. |
note | Optional short comment. |
BODY='{"type":"order.updated","orderId":"A-1042","status":"PAID"}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SALES_CORE_SECRET" | sed 's/^.* //')
curl -X POST "https://api.salescoreai.com/api/webhooks/connectors/$CONNECTOR_ID" \
-H "Content-Type: application/json" \
-H "X-AI-Sales-Timestamp: $TS" \
-H "X-AI-Sales-Signature: sha256=$SIG" \
-d "$BODY"| Answer | Meaning |
|---|---|
200 { "applied": true } | The order was found and updated. |
200 { "applied": false } | No order with this id came from your system. Check that you send the orderId from order.create. |
400 VALIDATION_ERROR | Unknown event type or status. |
401 INVALID_SIGNATURE | Wrong secret, a changed body or a clock off by more than 5 minutes. The owner is alerted. |
Every event, accepted or rejected, is shown in the connector’s exchange log in the dashboard. Limit: 120 events per minute.
Test mode
Business owners save test dialogues and replay them after every change. Your connector decides how much of it reaches your system.
| Tools | Without testMode | With "testMode": true |
|---|---|---|
Reads: catalog.*, order.status, booking.availability | Called as usual with context.test: true | Called as usual with context.test: true |
Actions and custom | Not called. The platform answers the agent with a plausible success, marked “simulated” in the report | Called with context.test: true; the idempotency key starts with test: |
If you declare testMode, a test call must run every check (stock, address, contact) and answer exactly like a real one, but change nothing: no order, booking or lead, no stock written off, no email sent.
async function createOrder(input, context) {
const problem = await validateOrder(input, context.customer); // stock, address, contact
if (problem) return { success: false, error: problem };
if (context.test) {
// Same answer as a real order, but nothing is stored, charged or emailed
return { success: true, data: previewOrder(input) };
}
const order = await placeOrderOnce(context.idempotencyKey, input, context);
return { success: true, data: order };
}Account endpoint
Optional. Show the business its Sales Core AI balance and monthly usage inside your own admin, with links to the dashboard.
A signed GET with an empty body, using the same secret. Limit: 60 requests per minute.
GET https://api.salescoreai.com/api/connectors/{connectorId}/account
{
"organization": { "name": "MatFit" },
"connector": { "name": "MatFit shop", "status": "ACTIVE" },
"balance": { "balanceUsd": 42.17, "lowBalanceUsd": 5, "low": false },
"usage": { "month": "2026-09", "usageUsd": 12.4, "commissionUsd": 3.1, "turns": 318 },
"links": {
"dashboard": "https://app.salescoreai.com/dashboard",
"billing": "https://app.salescoreai.com/billing",
"connector": "https://app.salescoreai.com/connections/{connectorId}"
}
}An unknown connector, a wrong or expired signature all return 401 { "error": { "code": "INVALID_SIGNATURE" } }.
Connect it in the dashboard
- 1
Add the system
Connected systems → Connect a system: a name and your base URL. Copy the secret into your project’s environment — it is shown once (you can rotate it later).
- 2
Load the tools
Open the system and press Refresh tools. The tools and their roles appear; read tools can be tried with Test.
- 3
Talk to the agent
Use the agent’s test chat. Every call is visible in the conversation’s run with its input, result and timing.
- 4
Send an event
Move a test order to
PAIDin your system and watch it land in Orders and in the exchange log.
Launch checklist
- Signature checked on the raw body, with the 5-minute window, on every route.
- Always HTTP 200 with
success; error messages tell the agent what to do next. - Actions are idempotent by
idempotencyKey. - Orders are found by
customerIdandpreviousCustomerIds. - Search results are compact; details live in
catalog.get. - Answers well under 15 seconds.
order.updatedis sent for every status change, withorderNumberif customers see a different number.- If you declare
testMode, test calls change nothing. - The chat snippet is on every page, and your domains are in the allowed list.
Integrating a shop, CRM or booking system?
We share the Node.js SDK and a reference connector, and help you design the tools.