Skip to content

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 model field 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's messages array is rejected with 422, 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.

HTTP: bearerAuth

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

Request schema
messages*array<object>
The full conversation so far, oldest first. Bounded by a FormRequest: a maximum message count and a maximum character length per message (exact numbers are config-driven and not yet finalized as of this writing; see this operation's description note above). `role: "system"` is rejected; the server owns the system prompt.
items: 1–∞
role*string
`system` is deliberately not an accepted value here and is rejected with `422` if sent.
enum: user assistant tool
example: user
content*string
example: How many extensions are currently unassigned?

Responses

The assistant's reply, the updated transcript, and accounting for the round(s) it took to produce it. Wrapped by `App\Services\ResponseFormatService::success()`, so the payload sits under `data`; that helper omits `data` entirely only when it is empty, which does not happen on this endpoint since a reply is always present on `200`.
Response schema
dataobject
replystring
The assistant's final prose answer for this turn.
example: There are 3 unassigned extensions on this PBX: 105, 118, and 142.
messagesarray<object>
The full transcript, including the client's own messages, any tool-result entries the server produced this turn, and the assistant's final reply appended at the end. Store and resend this array verbatim as `messages` on the next call.
rolestring
enum: user assistant tool
contentstring
tracearray<object>
One entry per tool call made while producing this reply, in order. Empty when the assistant answered without calling any tool.
namestring
example: list_extensions
argumentsobject
Free-form object
resultobject
The raw tool result (or a denial reason when `error` is true).
example: {"count":3,"extensions":[105,118,142]}
duration_msinteger
example: 184
errorboolean
True for both a tool-level failure and a permission denial. A denial is a normal outcome, not a bug: the assistant explains it in `reply` rather than the caller seeing an HTTP error.
example:
roundsinteger
How many OpenAI-then-maybe-a-tool rounds the server ran to produce this reply.
example: 2
usageobject
Token accounting for this call, as reported by OpenAI.
prompt_tokensinteger
example: 2140
cached_tokensinteger
Subset of `prompt_tokens` served from OpenAI's prompt cache (mainly the resent ~68 tool definitions), billed at a reduced rate. Accounted separately so a consumer of `cost` is not overstated several-fold.
example: 1890
completion_tokensinteger
example: 96
total_tokensinteger
example: 2236
costnumber | null
Estimated cost in USD from a server-side price table (`config/api.php`), not OpenAI's billing API. `null`, never `0.00`, when the configured model has no pricing entry.
example: 0.0071
messagestring
Only present when the server has something to say alongside `data` (not used on a normal reply).
Authorization Token Missing. This error is returned when the authorization token is missing.
Response schema
errorstring
Error message
example: Authorization Token is missing
The token presented is not a valid chat session token: either it carries the `*` wildcard ability (an ordinary app token, which must instead call `POST /api/v1/chat/session` first), it lacks `mcp:use`, or it has expired. The two rejection cases are deliberately not distinguished in the body, mirroring `POST /mcp/cockpit`'s `mcp.session` guard.
Response schema
messagestring
example: This action is unauthorized.
`messages` failed validation: a `role: "system"` entry, too many messages, a message over the per-message character limit, or an unrecognized `role` value.
Response schema
messagestring
errorsobject
Free-form object
Either the route throttle (`throttle:chat`, keyed on the user) or the separate per-user spend/round budget (AC6) was exceeded. Both are documented here as one status code because the ticket does not prescribe distinct codes for the two; confirm against the shipped controller.
Response schema
messagestring
A failing upstream OpenAI call, per AC11: reported as a clean error, never a silently swallowed failure and never left to surface as an unhandled `500` stack trace.
Response schema
messagestring
post https://cockpit.voxbi.com/api/v1/chat/message
Base URL
Request sample
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);
Sample request
{ … }
"messages": [ … ]
{ … }
"role": "user",
"content": "How many extensions are currently unassigned?"
}
]
}
{ … }
"data": { … }
"reply": "There are 3 unassigned extensions on this PBX: 105, 118, and 142.",
"messages": [ … ],
{ … },
"role": "user",
"content": "How many extensions are currently unassigned?"
},
{ … }
"role": "assistant",
"content": "There are 3 unassigned extensions on this PBX: 105, 118, and 142."
}
],
"trace": [ … ],
{ … }
"name": "list_extensions",
"arguments": { … },
"status": "unassigned"
},
"result": { … },
"count": 3,
"extensions": [ … ]
105,
118,
142
]
},
"duration_ms": 184,
"error": false
}
],
"rounds": 2,
"usage": { … },
"prompt_tokens": 2140,
"cached_tokens": 1890,
"completion_tokens": 96,
"total_tokens": 2236
},
"cost": 0.0071
}
}
{ … }
"error": "Authorization Token is missing"
}
Cache-Control string
example: private, must-revalidate
Connection string
example: keep-alive
Content-Type string
example: application/json
Vary string
example: Origin
X-RateLimit-Limit integer
Max requests allowed in the current rate-limit window.
example: 60
X-RateLimit-Remaining integer
Requests remaining in the current rate-limit window.
example: 57
No example for this status.
{ … }
"message": "The given data was invalid.",
"errors": { … }
"messages.0.role": [ … ]
"The selected messages.0.role is invalid."
]
}
}
{ … }
"message": "Too Many Attempts."
}
{ … }
"message": "Exception occurred while contacting the assistant."
}