Send a chat message to the Voxbi AI assistant (user)
Sends the caller's conversation to the hosted assistant and returns its reply, running as many tool-call rounds as needed against the caller's own permissions.
Not yet implemented at documentation time (T-012 AC12). This operation is written from the ticket's acceptance criteria, not from a shipped controller; see the report accompanying this change for every assumption made below, to reconcile once the endpoint lands.
Authentication
Requires the chat session token minted by
POST /api/v1/chat/session, never the caller's ordinary app token. The
guard chain is auth:sanctum then mcp.session then throttle:chat,
exactly like POST /mcp/cockpit: mcp.session rejects any token
carrying the * wildcard ability, so an ordinary app token gets a 403
here even though it authenticates the rest of the API.
The two token types are mutually exclusive by design, and this is the
single most common integration mistake: EnsureUserToken (the guard
behind every other /api/v1/* route) treats mcp:use as a NEGATIVE
ability, so a chat token is refused there in turn. An integrator needs
both tokens in play at once for a session: the app token to mint and
refresh the chat token, and the chat token, and only the chat token, to
send messages.
Token lifetime and re-minting
The chat token lives about 15 minutes
(config('api.mcp.session_ttl_minutes')). A conversation that outlives
that window gets a 403 on this endpoint; the client's own history is
unaffected, since the server holds none. The expected recovery is: call
POST /api/v1/chat/session again with the still-valid app token, then
resend the same messages array (plus the new user turn, if any) to
this endpoint with the new chat token. There is no partial-session
resume server-side, because there is no server-side session to resume.
The client owns the conversation history
The server does not persist messages (a chat_conversations table is a
tracked follow-up, out of scope here). Every call must include the full
transcript so far in messages, and the response's own messages array
is the authoritative transcript to store and resend next turn: it
includes the assistant's reply (and any intermediate tool-result
entries the server produced) appended to what was sent.
What the client does not control
- Model choice is server-owned. Any
modelfield in the request body is ignored, not honored: cost control is not the client's to make. - System prompts are server-owned. The server prepends its own
system message before calling OpenAI. A
role: "system"entry anywhere in the client'smessagesarray is rejected with422, it is never merged with or allowed to override the server's prompt.
Trace
trace has one entry per tool call the assistant made while producing
the reply: the tool name, the arguments the model chose, the raw tool
result, how long the call took, and whether it errored. A tool the
caller's permissions, role or active modules do not allow is a NORMAL
outcome, not a failure: it still appears in trace (with error: true
and the denial reason as its result), and the assistant is expected to
explain the refusal in its prose reply rather than the caller seeing
an HTTP error. Two different users asking the identical question can
legitimately get different tool catalogs and therefore different
traces; that is correct behavior driven by the per-caller tool catalog
(see the Mcp tag's tools/list), not a bug.
Cost is an estimate
cost is computed from a server-side price-per-token table
(config/api.php), not from OpenAI's billing API, and separates
cached_tokens out of the prompt count because the tool catalog
(roughly 68 definitions) is resent every round and priced far below a
fresh prompt token. When the configured model has no entry in that
table, cost is null, deliberately never 0.00, since a zero reads
as "this was free."
Round ceiling and budget
Each call to OpenAI-then-maybe-a-tool is one round; rounds reports how
many the server actually ran. Hitting the configured ceiling ends the
loop with whatever prose the assistant could produce given what it
learned, returned as a normal 200 with a reply that says the
assistant stopped, not an error. A per-user spend or round budget
(config-driven, separate from the per-round ceiling) is also enforced;
exceeding it fails the request rather than the round, see 429 below.
User bearer token. Default authentication for customer-facing endpoints.
Obtain a token by calling POST /login with your credentials, then send it
on every subsequent request as Authorization: Bearer <token>. The token
inherits the permissions and PBX scope of the authenticated user.
- HTTP Authorization Scheme
bearer- Bearer format
Bearer <token>
Request body · required · application/json
messages*array<object>
user assistant tooluserHow many extensions are currently unassigned?Responses
dataobject
There are 3 unassigned extensions on this PBX: 105, 118, and 142.messagesarray<object>
user assistant tooltracearray<object>
list_extensionsargumentsobject
{"count":3,"extensions":[105,118,142]}1842usageobject
214018909622360.0071Authorization Token is missingThis action is unauthorized.errorsobject
https://cockpit.voxbi.com/api/v1/chat/message
curl -X POST 'https://cockpit.voxbi.com/api/v1/chat/message' \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'Content-Type: application/json' \
--data '{"messages":[{"role":"user","content":"How many extensions are currently unassigned?"}]}'
const response = await fetch('https://cockpit.voxbi.com/api/v1/chat/message', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${YOUR_TOKEN}`,
},
body: JSON.stringify({
"messages": [
{
"role": "user",
"content": "How many extensions are currently unassigned?"
}
]
}),
});
const data = await response.json();
console.log(data);
import requests
response = requests.post('https://cockpit.voxbi.com/api/v1/chat/message',
headers={'Authorization': f'Bearer {YOUR_TOKEN}'},
json={
"messages": [
{
"role": "user",
"content": "How many extensions are currently unassigned?"
}
]
}
)
response.raise_for_status()
data = response.json()
print(data)
<?php
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\nAuthorization: Bearer YOUR_TOKEN",
'content' => '{
\"messages\": [
{
\"role\": \"user\",
\"content\": \"How many extensions are currently unassigned?\"
}
]
}',
],
]);
$response = file_get_contents('https://cockpit.voxbi.com/api/v1/chat/message', false, $context);
$data = json_decode($response, true);
print_r($data);
{ … }
"messages": [ … ]
{ … }
{ … }
"data": { … }
"messages": [ … ],
{ … },
{ … }
"trace": [ … ],
{ … }
"arguments": { … },
"result": { … },
"extensions": [ … ]
"usage": { … },
{ … }
private, must-revalidatekeep-aliveapplication/jsonOrigin6057