Workflows
Use VetsGPT
Ask Vetnio's clinical assistant questions with consultation, patient and file context, retry safely with idempotency keys, and manage chats over the API.
VetsGPT is Vetnio's clinical assistant. Over the API you can ask it a question from your own software, with a consultation, a patient or an uploaded file as context, and get back a cited answer as JSON. Typical uses are a "Ask VetsGPT" button in a practice system or a tool that interprets lab reports during a visit.
A chat is one conversation with VetsGPT, owned by one vet and optionally shared with clinic colleagues. This guide creates a chat, gives it context, uploads a PDF, asks a question, and then reads, rates, renames and deletes the chat.
Before you start
- A personal API key of the vet who asks. The API uses that vet's chat permissions. Organization and clinic API keys get
403; see Authentication. - VetsGPT enabled for the clinic and for that user. The user must also be a member of the clinic. This applies to clinic admins too, and organization admins who are not members of the clinic cannot use VetsGPT there. Otherwise every VetsGPT call fails with
403, the messageThe vetsGPT module is not enabledand adata.reasonthat says why (see Common errors). - A workspace ID and, for context, the IDs of the consultations or patients you want VetsGPT to read.
Chat access
Chats are private by default. The owner can grant selected current clinic members access in the Vetnio app; sharing itself is not exposed through the public REST API. Clinic and organization admins have no automatic access.
| Permission | API actions |
|---|---|
view | List the chat, read its messages and attached context |
contribute | Also ask questions, upload files and rate answers |
owner | Also change linked consultations or patient, rename and delete the chat |
Create and list responses include sharing, with your permission, the owner, isShared and recipient counts. A missing chat or insufficient permission for an action returns 404 Chat not found, including when a viewer tries to ask or a contributor tries to rename.
Sharing a chat does not grant access to its linked consultations or patient records. VetsGPT checks the asking vet's source access. Files already attached to an accepted message are readable by chat recipients; an upload not yet submitted in a message remains private to its uploader.
1. Create a chat with context
You can ask a one-off question without creating a chat first (see step 4), but you need a chat to upload files, because upload URLs are issued per chat. POST/workspaces/{workspaceId}/vetsgpt/chats creates an empty chat and attaches up to 50 consultations as context:
curl -X POST https://vetnio.com/api/v2/workspaces/demo-north-clinic/vetsgpt/chats \
-H "Authorization: Bearer $VETNIO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Nala - dental follow-up",
"consultationIds": ["62a25f84-3c4f-4402-b82a-d8f7e6fd9dee"]
}'{
"chat_id": "4610f54a-7f0b-422b-8d32-3e3713bf9d51",
"user_id": "c1b0f2a0-f819-5fcc-a2ed-85db0cef66a7",
"workspace_id": "demo-north-clinic",
"title": "Nala - dental follow-up",
"created_at": "2026-10-04T16:14:10.802Z",
"last_updated_at": "2026-10-04T16:14:10.802Z",
"messages": [],
"sharing": {
"permission": "owner",
"owner": { "id": "c1b0f2a0-f819-5fcc-a2ed-85db0cef66a7", "name": null },
"isShared": false,
"recipientCount": 0,
"firstRecipientName": null
}
}This endpoint is not idempotent: every call creates a new chat. If a consultation ID is unknown or not readable, the call fails with 404 but the chat has already been created.
The title you set is a starting point. After the first message that has a real topic (a greeting does not count), VetsGPT generates a short title in the background and replaces yours. To keep your own title, set it with the rename endpoint (step 7).
2. Attach or change context
Only the owner can change linked context. It is stored on the chat for later questions; each submitted question keeps the context it started with, including on retry:
| Context | Set with | What VetsGPT reads |
|---|---|---|
| Consultations (up to 50) | PUT/workspaces/{workspaceId}/vetsgpt/chats/{chatId}/attachments/consultations with the complete new list; [] clears it | Each consultation's name, date, transcript, clinical note, client communication and linked patient, re-checked for read access on every message |
| One patient | PUT/workspaces/{workspaceId}/vetsgpt/chats/{chatId}/attachments/patient, removed with DELETE/workspaces/{workspaceId}/vetsgpt/chats/{chatId}/attachments/patient | The patient's details, up to five recent clinical notes and up to five analyzed patient files. Used only while no consultations are attached. |
The patient ID is not validated: an unknown ID is stored but gives no context. Check what is attached with GET/workspaces/{workspaceId}/vetsgpt/chats/{chatId}/attachments:
curl https://vetnio.com/api/v2/workspaces/demo-north-clinic/vetsgpt/chats/4610f54a-7f0b-422b-8d32-3e3713bf9d51/attachments \
-H "Authorization: Bearer $VETNIO_API_KEY"{
"consultations": [
{ "id": "62a25f84-3c4f-4402-b82a-d8f7e6fd9dee", "name": "Nala - dental check" }
],
"patient": {
"id": "1c9c1cf7-9796-477d-bac0-c5da7a51432a",
"name": "Nala",
"species": "Cat - Maine Coon",
"identifier": "752098100067890"
}
}Despite its path, this endpoint does not list uploaded files. In this example the patient is ignored while the consultation is attached, because the consultation brings its own linked patient.
VetsGPT also uses the clinic's and organization's knowledge material and the vet's own instructions, as it does in the app.
3. Upload a file
To ask about a lab report, an image or another document, upload it to the chat. POST/workspaces/{workspaceId}/vetsgpt/chats/{chatId}/attachments/upload-url returns a pre-signed upload URL and the file's key:
curl -X POST https://vetnio.com/api/v2/workspaces/demo-north-clinic/vetsgpt/chats/4610f54a-7f0b-422b-8d32-3e3713bf9d51/attachments/upload-url \
-H "Authorization: Bearer $VETNIO_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "filename": "nala_dental_xray_report.pdf" }'{
"chatAttachmentUploadUrl": "https://storage.example.com/chats/demo-north-clinic/4610f54a-7f0b-422b-8d32-3e3713bf9d51/attachments/3186d6ee-6012-4a38-9fa7-b2a64bf7d00b?X-Amz-Expires=86400&X-Amz-Signature=…",
"key": "chats/demo-north-clinic/4610f54a-7f0b-422b-8d32-3e3713bf9d51/attachments/3186d6ee-6012-4a38-9fa7-b2a64bf7d00b"
}PUT the raw bytes to the URL within 24 hours. It is a storage URL, not a Vetnio endpoint, so send no Authorization header. Do send the file's real Content-Type: VetsGPT decides how to read the file from it.
curl -X PUT "$UPLOAD_URL" \
-H "Content-Type: application/pdf" \
--data-binary @nala_dental_xray_report.pdfA 200 with an empty body means the upload worked. VetsGPT reads images, PDFs, plain text, CSV, Word (.docx) and Excel files, and uses up to 100,000 extracted characters per file. If no text can be read from a file, for example because it has another type, was uploaded as application/octet-stream or contains no readable content, the ask fails with 412 and the message attachmentExtractionFailed, and no answer is generated.
4. Ask a question
POST/workspaces/{workspaceId}/vetsgpt/ask sends one message, waits until the answer is complete, and returns both stored messages. Pass the file's key in pdfKeys (or imageKeys; both are handled the same way):
curl -X POST https://vetnio.com/api/v2/workspaces/demo-north-clinic/vetsgpt/ask \
-H "Authorization: Bearer $VETNIO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"chatId": "4610f54a-7f0b-422b-8d32-3e3713bf9d51",
"message": "Summarize the radiograph report and list the follow-up options.",
"idempotencyKey": "6f1d2c9a-7b3e-4f20-9d8a-0c5e1b7a4f33",
"pdfKeys": ["chats/demo-north-clinic/4610f54a-7f0b-422b-8d32-3e3713bf9d51/attachments/3186d6ee-6012-4a38-9fa7-b2a64bf7d00b"],
"categoryHint": "clinical"
}'An answer has this shape (content abridged; real answers vary):
{
"chat": {
"chatId": "4610f54a-7f0b-422b-8d32-3e3713bf9d51",
"title": "Nala - dental follow-up",
"createdAt": "2026-10-04T16:14:10.802Z",
"updatedAt": "2026-10-04T16:19:02.415Z"
},
"userMessage": {
"messageId": "rest-user-message-5c0e9a7d2b4f4e18a6d3c1f07b9e2a64",
"timestamp": "2026-10-04T16:18:31.207Z",
"role": "user",
"content": "Summarize the radiograph report and list the follow-up options.",
"attachments": ["chats/demo-north-clinic/4610f54a-7f0b-422b-8d32-3e3713bf9d51/attachments/3186d6ee-6012-4a38-9fa7-b2a64bf7d00b"],
"feedback": "none"
},
"assistantMessage": {
"messageId": "rest-assistant-message-a81f3d6c0e2b47d9b5c4e7f1029d3b8e",
"timestamp": "2026-10-04T16:19:02.415Z",
"role": "assistant",
"content": "**Radiograph findings**\n- … (Source: Attachment analysis)\n- … (Source: Consultation 1 - Clinical note)\n\n**Follow-up options**\n- … <https://example.org/feline-dental-guidelines>",
"feedback": "none"
},
"progressEvents": ["analyzing_question", "searching_sources", "generating_answer"]
}What to know about the request and the answer:
- It is synchronous and slow. An answer involves classifying the question, searching the web and the clinic's knowledge material, and writing the answer, so it takes far longer than other endpoints. Use a long client timeout, and read Limits for the request time limit.
- Starting a chat. Without
chatId, the call creates a chat whose ID is derived from the idempotency key:rest-chat-<32 hex characters>. You can still passconsultationIdsandpatientId, which are stored on the chat as in step 2. - Context per message. For the owner, a non-empty
consultationIdsreplaces the chat's consultations before a new question; leave it out to keep them. Contributors must omitconsultationIdsandpatientId, even if the values are unchanged. Files are read for this message only: to use a file again in a later message, send its key again. categoryHintoverrides VetsGPT's own classification:clinical(with web search and citations) oradministrative(no web search).- The answer is Markdown and may contain LaTeX (
$…$). Sources are cited inline, not in a separate field: web pages as<https://…>links, clinic documents as(Source: <title>, p. <page>), consultation data as(Source: Consultation <n> - Clinical note)and uploaded files as(Source: Attachment analysis). progressEventslists the phases VetsGPT went through. It is informational and empty for a replayed answer.
Streaming, Deep Research and the editing actions of the consultation side panel are not available through the API.
5. Retry safely
Every ask needs an idempotencyKey of 8 to 128 characters; use a new UUID for each question. Replays are scoped to the calling vet and chat. Keep the question, file keys and categoryHint unchanged. Treat the returned message IDs as opaque.
| Situation | What a retry with the same key does |
|---|---|
| The answer is stored | Returns it immediately, with progressEvents: []. Nothing is generated again. |
| The first request is still generating (for example your client timed out) | Waits for that answer and returns it, or returns 409 with VetsGPT answer is still generating. Retry later with the same key. |
| Generation was interrupted | Returns 409 Turn interrupted; retry explicitly. A network replay does not restart generation. After the vet chooses to retry, resend the same request with retry: true. Only the original sender can restart the latest interrupted question, and only before later messages or another generation. |
| Another question is generating | Returns 409 CHAT_BUSY. Wait, then replay the same request. |
| The question, files or generation options changed under the same key | Returns 409 REQUEST_MISMATCH. Use a new key for a new question. |
| An explicit retry is no longer possible | Returns 409 RETRY_NOT_ALLOWED. Read the conversation before sending a new question. |
6. Read the conversation
GET/workspaces/{workspaceId}/vetsgpt/chats/{chatId}/messages returns the chat's messages, oldest first. Its format differs from the ask response: fields are in snake_case, type is user_message or bot_message, and attachments come back as base64 data URIs of the whole file, which can make the response large:
[
{
"message_id": "rest-user-message-5c0e9a7d2b4f4e18a6d3c1f07b9e2a64",
"timestamp": "2026-10-04T16:18:31.207Z",
"type": "user_message",
"content": {
"text": "Summarize the radiograph report and list the follow-up options.",
"attachments": ["data:application/pdf;name=nala_dental_xray_report.pdf;base64,JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4K…"]
},
"feedback": "none"
},
{
"message_id": "rest-assistant-message-a81f3d6c0e2b47d9b5c4e7f1029d3b8e",
"timestamp": "2026-10-04T16:19:02.415Z",
"type": "bot_message",
"content": { "text": "**Radiograph findings**\n- … (Source: Attachment analysis)…" },
"feedback": "none"
}
]An unknown chat, or a chat the caller cannot read, returns 404 Chat not found.
Edit proposals
When a vet uses VetsGPT from a consultation in the Vetnio web app, an answer can carry copilotProposals: edits that VetsGPT proposed to that consultation's clinical note (target: clinical_note) or client communication (communication). Each proposal has an operation:
operation.kind | Carries | Meaning |
|---|---|---|
patch | edits: 1 to 20 { find, replace } pairs | Replace those lines of the document's HTML; an empty replace removes them |
replace_all | html | The complete new document |
status says what happened to it (pending, applied, accepted, rejected, undone, or stale when the document changed after the proposal was written, which baseHash detects). Proposals are read-only through the API: they are applied, accepted or rejected in the Vetnio editor, and the result is visible through GET/workspaces/{workspaceId}/consultations/{consultationId}/records. Shared chats support research; sharing does not enable these editor actions.
7. Rate, rename, list and delete
Owners and contributors can rate an answer with PUT/workspaces/{workspaceId}/vetsgpt/chats/{chatId}/messages/{messageId}/feedback: positive, negative, or none to clear the rating. Ratings go to Vetnio's product analytics.
curl -X PUT https://vetnio.com/api/v2/workspaces/demo-north-clinic/vetsgpt/chats/4610f54a-7f0b-422b-8d32-3e3713bf9d51/messages/rest-assistant-message-a81f3d6c0e2b47d9b5c4e7f1029d3b8e/feedback \
-H "Authorization: Bearer $VETNIO_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "feedback": "positive" }'{
"success": true
}The owner can rename the chat with PATCH/workspaces/{workspaceId}/vetsgpt/chats/{chatId} (1 to 100 characters). A title set this way is kept; VetsGPT stops generating titles for the chat:
curl -X PATCH https://vetnio.com/api/v2/workspaces/demo-north-clinic/vetsgpt/chats/4610f54a-7f0b-422b-8d32-3e3713bf9d51 \
-H "Authorization: Bearer $VETNIO_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "title": "Nala - dental radiographs" }'{
"success": true
}GET/workspaces/{workspaceId}/vetsgpt/chats returns owned and explicitly shared chats, newest first by conversation activity (last_updated_at), with a stable chat-ID order for ties. It is not paginated. searchQuery matches titles and accessible message text, case-insensitively. Search responses include searchable_content when accessible; ordinary lists omit it. Each item includes your sharing permissions:
curl "https://vetnio.com/api/v2/workspaces/demo-north-clinic/vetsgpt/chats?searchQuery=radiograph" \
-H "Authorization: Bearer $VETNIO_API_KEY"[
{
"chat_id": "4610f54a-7f0b-422b-8d32-3e3713bf9d51",
"title": "Nala - dental radiographs",
"created_at": "2026-10-04T16:14:10.802Z",
"last_updated_at": "2026-10-04T16:19:40.112Z",
"searchable_content": "Summarize the radiograph report and list the follow-up options.\n…",
"sharing": {
"permission": "owner",
"owner": { "id": "c1b0f2a0-f819-5fcc-a2ed-85db0cef66a7", "name": null },
"isShared": false,
"recipientCount": 0,
"firstRecipientName": null
}
}
]Only the owner can call DELETE/workspaces/{workspaceId}/vetsgpt/chats/{chatId}. The chat disappears for everyone and can no longer be read or continued; this cannot be undone. A second delete returns 404.
curl -X DELETE https://vetnio.com/api/v2/workspaces/demo-north-clinic/vetsgpt/chats/4610f54a-7f0b-422b-8d32-3e3713bf9d51 \
-H "Authorization: Bearer $VETNIO_API_KEY"{
"success": true
}Limits
| Item | Limit |
|---|---|
message | 1 to 50,000 characters |
idempotencyKey | 8 to 128 characters |
| Chat title | 1 to 100 characters |
| Consultations per chat | 50 (more currently fails with 500) |
| Upload URL | Valid for 24 hours |
| Extracted text per file | 100,000 characters |
| Chat list | Not paginated |
Common errors
| Status | Message | Cause | Fix |
|---|---|---|---|
400 | Input validation failed | For example an empty message or a short idempotencyKey. | Fix the field named in issues. |
403 | The vetsGPT module is not enabled | data.reason says why: module_not_enabled_for_member (off for this user), module_not_enabled_for_workspace (off for the clinic), module_not_workspace_member (the user is not a member of the clinic, for example an organization admin) or module_workspace_unavailable (the clinic is locked, or being merged or erased). | Ask a clinic admin to enable VetsGPT, or use the key of a clinic member. |
403 | This API key cannot access this endpoint | Organization or clinic API key. | Use the vet's personal API key. |
404 | Chat not found | Unknown chat, no current grant, or insufficient permission for the requested action. | Check the chat ID, the key's user and the required permission. |
404 | Consultation not found | A consultation in consultationIds is unknown or not readable. | Remove it from the list. |
409 | VetsGPT answer is still generating | A request with the same key is still running. | Retry later with the same key. |
409 | CHAT_BUSY | Another question is being answered in the chat. | Wait, then replay the same request. |
409 | Turn interrupted; retry explicitly | The previous attempt stopped before completing. | Offer an explicit retry using the same request and retry: true. |
409 | REQUEST_MISMATCH or RETRY_NOT_ALLOWED | Changed payload under an existing key, or a retry that can no longer restart. | Read the conversation and use a new key for a new question. |
412 | attachmentExtractionFailed | No text could be read from a file in pdfKeys or imageKeys: an unsupported or generic Content-Type, an unknown key, or no readable content. | Upload the file again with its real Content-Type, or ask without it. |
500 | An unexpected error occurred | Generation or another operation failed. | Replay with the same key to find the outcome; an interrupted question needs an explicit retry as described above. |
See Errors for the error format.
Complete example
This Node.js (18+) script creates a chat for a consultation, uploads a PDF, asks a question with retries under one idempotency key, and rates the answer.
import { randomUUID } from "node:crypto";
import { readFile } from "node:fs/promises";
const base = "https://vetnio.com/api/v2/workspaces/demo-north-clinic";
const headers = { Authorization: `Bearer ${process.env.VETNIO_API_KEY}`, "Content-Type": "application/json" };
async function call(method, path, body) {
const response = await fetch(base + path, { method, headers, body: body && JSON.stringify(body) });
const payload = await response.json();
if (!response.ok) throw Object.assign(new Error(payload.message), { status: response.status });
return payload;
}
const chat = await call("POST", "/vetsgpt/chats", { consultationIds: ["62a25f84-3c4f-4402-b82a-d8f7e6fd9dee"] });
const chatId = chat.chat_id;
const { chatAttachmentUploadUrl, key } = await call("POST", `/vetsgpt/chats/${chatId}/attachments/upload-url`, {
filename: "nala_dental_xray_report.pdf",
});
const upload = await fetch(chatAttachmentUploadUrl, {
method: "PUT",
headers: { "Content-Type": "application/pdf" },
body: await readFile("nala_dental_xray_report.pdf"),
});
if (!upload.ok) throw new Error(`Upload failed with ${upload.status}`);
// One key per message; every retry of this message reuses it.
const question = {
chatId,
message: "Summarize the radiograph report and list the follow-up options.",
idempotencyKey: randomUUID(),
pdfKeys: [key],
};
let answer;
for (let attempt = 1; !answer; attempt += 1) {
try {
answer = await call("POST", "/vetsgpt/ask", question);
} catch (error) {
const waiting = error.status === 409 &&
["VetsGPT answer is still generating", "CHAT_BUSY"].includes(error.message);
const retryable = error.status === undefined || waiting || error.status >= 500;
if (!retryable || attempt === 5) throw error;
await new Promise((resolve) => setTimeout(resolve, attempt * 10_000));
}
}
console.log(answer.assistantMessage.content);
await call("PUT", `/vetsgpt/chats/${chatId}/messages/${answer.assistantMessage.messageId}/feedback`, { feedback: "positive" });Next steps
- Patients and clients: the patient records you can attach as context.
- Document a visit end to end: create the consultations VetsGPT reads.
- Limits and Errors: time limits, rate limits and status codes.
- The VetsGPT reference documents every field.
Endpoints used in this guide
- GET
/workspaces/{workspaceId}/consultations/{consultationId}/recordsGet a record - POST
/workspaces/{workspaceId}/vetsgpt/chatsCreate a VetsGPT chat - PUT
/workspaces/{workspaceId}/vetsgpt/chats/{chatId}/attachments/consultationsSet VetsGPT consultation context - PUT
/workspaces/{workspaceId}/vetsgpt/chats/{chatId}/attachments/patientSet VetsGPT patient context - DELETE
/workspaces/{workspaceId}/vetsgpt/chats/{chatId}/attachments/patientRemove VetsGPT patient context - GET
/workspaces/{workspaceId}/vetsgpt/chats/{chatId}/attachmentsGet VetsGPT chat attachments - POST
/workspaces/{workspaceId}/vetsgpt/chats/{chatId}/attachments/upload-urlGet a VetsGPT attachment upload URL - POST
/workspaces/{workspaceId}/vetsgpt/askSend a message to VetsGPT - GET
/workspaces/{workspaceId}/vetsgpt/chats/{chatId}/messagesGet VetsGPT chat messages - PUT
/workspaces/{workspaceId}/vetsgpt/chats/{chatId}/messages/{messageId}/feedbackUpdate VetsGPT message feedback - PATCH
/workspaces/{workspaceId}/vetsgpt/chats/{chatId}Update a VetsGPT chat title - GET
/workspaces/{workspaceId}/vetsgpt/chatsList VetsGPT chats - DELETE
/workspaces/{workspaceId}/vetsgpt/chats/{chatId}Delete a VetsGPT chat
Last reviewed against the API on 2026-10-30.