1.Basics
The user enters your server’s base URL in Settings, such as
https://ai.example.com or http://192.168.1.20:8000. Every path below
is under /v1 on it. Use whatever language, framework and models you like: the app
only cares about the requests and responses on this page.
The AI agent runs on your server. The app sends each turn of a chat with the settings the user chose, and your server writes the prompts, calls its models, uses its tools, makes the media and streams back what it does (section 4). The app keeps the chats on the device and shows what comes back.
- JSON everywhere, except file uploads, which are
multipart/form-data. - Errors. Any response that is not 2xx should carry this body. The app shows
messageto the user, so write it for a person.
{
"error": {
"message": "The image model is not installed."
}
} - Authentication. The app sends no credentials of its own. If your server sits
behind Cloudflare Access, the user can enter a service token under General in Settings, and
the app adds
CF-Access-Client-IdandCF-Access-Client-Secretheaders to every request to that host, voice mode included. Anything else, keep on a private network. - HTTPS is recommended for anything reachable from the internet. Plain
http://works, and the app warns the user that it is not encrypted. - Optional parts. A server can start with just the health check, the models list and the agent turn. Media and voice mode can come later; until then, have your agent tell the user what it can’t do.
2.Health check
GET /v1/health
Called when the user tests the connection in Settings, and before each agent turn. Any 2xx response counts as healthy, and the body is ignored.
{
"ok": true
} 3.Models and settings
GET /v1/models
The app reads this when Settings opens and when it comes back to the foreground, so a model or
a setting you add on the server shows up without an app update. It has two parts:
models, the text models the user chats with, and catalog, the media
models and their settings. The app builds its Models settings from it, and sends back what the
user chose with every agent turn (section 4).
{
"models": [
{
"id": "my-model",
"name": "My Model",
"params": [
{
"key": "temperature",
"label": "Temperature",
"type": "float",
"default": 0.8,
"min": 0,
"max": 2,
"step": 0.05
},
{
"key": "thinking",
"label": "Thinking",
"type": "bool",
"default": true
}
]
}
],
"catalog": {
"image": {
"label": "Image",
"models": [
{
"id": "image-a",
"name": "Image A",
"about": "Fast, keeps one face"
},
{
"id": "image-b",
"name": "Image B",
"about": "Combines several pictures",
"params": [
{
"key": "film_look",
"label": "Film look",
"type": "bool",
"default": false,
"ask": false
}
]
}
],
"params": [
{
"key": "aspect",
"label": "Aspect",
"type": "enum",
"default": "auto",
"options": [
"auto",
"16:9",
"1:1",
"9:16"
]
},
{
"key": "resolution",
"label": "Megapixels",
"type": "enum",
"default": 2,
"options": [
1,
2,
4
]
}
]
},
"video": {
"label": "Video",
"models": [
{
"id": "video-a",
"name": "Video A"
}
],
"params": [
{
"key": "duration",
"label": "Length (s)",
"type": "int",
"default": 5,
"min": 1,
"max": 30
}
]
},
"music": {
"label": "Music",
"models": [
{
"id": "music-a",
"name": "Music A"
}
],
"params": []
},
"speech": {
"label": "Speech",
"models": [
{
"id": "voice-a",
"name": "Voice A"
}
],
"params": [
{
"key": "gender",
"label": "Voice",
"type": "enum",
"default": "female",
"options": [
"female",
"male"
]
}
]
}
}
} Text models (models), default first:
id(required) is what the app sends back asmodel.nameis shown to the user.-
params: settings the user can change for it (below), such astemperatureandthinking. - Anything else you add, such as what a model can read or make, is for your own agent; the app ignores it.
Media catalog (catalog): one entry per kind, keyed
image, video, music and speech. Each has a
label, its models (default first) and the params every
model of that kind takes. A model can add its own params, which apply only to what
it makes. A kind you leave out has no page in the app’s settings.
A setting is an object with these fields:
key,labelandtype:int,float,boolorenum.default, and for numbersmin,maxandstep.optionsfor anenum: strings or numbers.hint: an optional line of help shown under it.-
ask: the user can leave a setting on Ask, and your agent should then ask them in the chat before making anything."ask": falsemarks a setting that is never asked about; until the user sets it, it is itsdefault.
4.The agent
Each time the user sends a message, the app first makes sure your server has the chat’s
files, then sends the turn and reads the reply as it streams. A server without these paths
should answer 404, and the app tells the user it doesn’t run the Canopus
agent.
POST /v1/agent/files/missing
Before each turn, the app asks which of the chat’s files your server does not have yet:
{"files": ["<name>", ...]}, answered with
{"missing": ["<name>", ...]}. A name is the one the app keeps the file under
(a UUID with an extension), and it never changes.
POST /v1/agent/files
The missing files then go up one per request, as multipart/form-data with one file
part whose filename is that name. Keep them for later turns. Each name is checked again before
every turn, so you may delete files you have not needed for a while.
POST /v1/agent/turn
{
"model": "my-model",
"media_mode": null,
"instructions": "",
"user_name": "Sam",
"zone": "Europe/London",
"now": "2026-10-16T18:30:00.000",
"prefs": {
"engines": {
"image": "image-a"
},
"values": {
"image": {
"aspect": "16:9"
},
"my-model": {
"temperature": 0.6
}
}
},
"history": [
{
"role": "user",
"text": "Draw me as a pirate",
"attachments": [
{
"kind": "image",
"mime": "image/jpeg",
"name": "IMG_0412.jpg",
"file": "3f2a9c1e-7b4d-4e8a-9f61-2c5d8b0e4a17.jpg"
}
]
}
]
} -
history: the newest 40 messages, oldest first, ending with the user’s newest. Each attachment (image,video,audioorpdf) names a file sent above. -
model: the chat’s text model, ornullfor your default. Treat an unknown id as your default too.media_mode: what the user picked in the composer (image,video, oraudio: speech or music, whichever fits), ornull. Older apps sendspeechormusic.instructions: the chat’s own system prompt, or empty. -
nowis the device’s local time andzoneits time zone, so “now” is the user’s time wherever your server is.user_nameis what the user asked to be called, or empty. -
prefs: the user’s choices in Settings > Models.enginesmaps a media page (image,video,speech,voice) to a model id from your catalog;valuesmaps a media page or a text model id to its settings. Anything missing is on Ask. Check them: drop keys you do not know and keep values within range.
Answer with server-sent events, one JSON object per data: line:
statuswhile working, such asSearching: ...; the app shows it under the reply.textas the reply is written, in Markdown. The app joins the pieces.-
jobfor each picture, video, song or recording you start making, with itsjob_id,kindand a shortlabel. The app follows it with section 5 and adds the files to the reply when they are done, even if the user has closed the app meanwhile. -
{"type": "ping"}at least every 30 seconds while the agent works without saying anything. -
Finish with
{"type": "done"}, or{"type": "error", "message": "...", "status": 500}.
The app closes the connection when the user taps Stop.
data: {"type":"status","text":"Thinking…"}
data: {"type":"text","text":"Making it now."}
data: {"type":"job","job_id":"a1b2c3","kind":"image","label":"1 image(s)"}
data: {"type":"done"}
POST /v1/agent/enhance
The instructions screen’s Enhance:
{"model": "my-model", "draft": "..."}, answered with
{"text": "..."}, the draft rewritten into a full system prompt.
5.Job status
GET /v1/jobs/{job_id}
The app asks every few seconds while a job runs. Jobs should outlive the app: the user may close it and come back later for the result.
{
"status": "done",
"label": "2 image(s)",
"progress": "Drawing 2/2",
"result": {
"attachments": [
{
"kind": "image",
"mime": "image/png",
"name": "beach-1.png",
"url": "media/beach-1.png"
},
{
"kind": "image",
"mime": "image/png",
"name": "beach-2.png",
"url": "media/beach-2.png"
}
]
},
"error": null
} status:queued,running,doneorfailed.progress: an optional line shown while it runs.result, once it isdone: the files it made.error, once it hasfailed: why, for the user.-
Each attachment has a
kind(image,video,audioorpdf), amimetype, a filenameand aurl. The URL can be absolute, or relative to the base URL. - Videos should be H.264 MP4, and sounds AAC (
.m4a) or MP3, so the device can play them.
6.Media files
The app downloads each file as soon as its job is done and keeps its own copy, so a file only has to stay available until then.
DELETE /v1/media/{name}
Sent when the user deletes a conversation, for each file your server made for it, by its
name. Delete the file. Any 2xx response is fine, and the body is ignored.
7.Backup
Under Data in Settings, Back up to server sends every chat, archived ones
included, with its files, and Restore from server brings the last backup back.
Keep one backup: each new one replaces the one before, so a chat the user deleted since the
last backup is gone after the next, together with its files. File names are the ones the app
keeps its files under (a UUID with an extension). A server without these paths answers
404, and the app shows the error.
POST /v1/backup/files/missing
First the app asks which of the chats’ files the backup does not have yet:
{"files": ["<name>", ...]}, answered with
{"missing": ["<name>", ...]}. A later backup only sends new files.
POST /v1/backup/files
The missing files then go up one per request, as multipart/form-data with one file
part whose filename is that name.
PUT /v1/backup
{
"version": 1,
"created_at": 1792765800000,
"conversations": [
{
"id": "<uuid>",
"title": "Trip photos",
"archived": 1,
"created_at": 1792765000000,
"updated_at": 1792765800000,
"...": "...",
"messages": [
{
"id": "<uuid>",
"role": "user",
"text": "make it brighter",
"created_at": 1792765000000,
"...": "...",
"attachments": [
{
"id": "<uuid>",
"kind": "image",
"mime": "image/jpeg",
"name": "IMG_1.jpg",
"local_path": "<name>",
"size_bytes": 1830211,
"...": "..."
}
]
}
]
}
]
}
Last come the chats, as the app keeps them: each conversation with its messages, each message
with its attachments. Store the body as it is; the app may add fields. archived is
1 for a chat in the archive, and local_path names a file sent above.
Answer 400 if a file is missing. Replace the old backup only once the new one is
saved, then delete the files it no longer names.
GET /v1/backup
Restoring starts here: answer with the body of the last PUT, or 404
when there is no backup.
GET /v1/backup/files/{name}
Then each file the device does not have yet. The app downloads every file before it changes anything, puts each chat in the backup in place of its own copy (archived ones go back to the archive), and keeps chats that are not in it.
8.Voice mode
WS /v1/call
Voice mode is one WebSocket per conversation: the app sends what the user says as sound, and your server hears it, writes the reply and speaks it back sentence by sentence. Messages are JSON text frames, except the spoken reply, which comes as binary frames.
1. The app opens the call:
{
"type": "start",
"model": "my-model",
"engine": "voice-a",
"options": {
"gender": "female"
},
"llm": {
"temperature": 0.7
},
"language": "en"
} model is the text model, engine and options the voice
(from your speech catalog), llm the text model’s settings and
language the language to speak at first. Get ready, then send
{"type": "ready"}. To tell the user something, such as another voice standing
in for the one they chose, send {"type": "note", "text": "..."} first.
2. Each time the user pauses, the app sends
{"type": "audio", "data": "<base64 WAV>"}. Answer with
{"type": "heard", "text": "...", "language": "en"}, with an empty
text if it was not speech. Reply in the language the user last spoke.
A turn longer than 30 seconds comes in pieces: each one but the last carries
"partial": true. Don’t answer a piece on its own. Send one
heard for the whole turn, with the pieces’ text joined in order, once the
last piece arrives.
3. The app then sends the turn: the chat’s newest 40 messages that have text, with the
same now, zone, user_name and instructions
as an agent turn. Write the call’s prompt from it. With tools, the model may
search the web; send {"type": "searching"} while it does.
{
"type": "turn",
"tools": true,
"voice": {
"history": [
{
"role": "user",
"text": "What is the weather like tomorrow?"
}
],
"now": "2026-10-16T18:30:00.000",
"zone": "Europe/London",
"user_name": "Sam",
"instructions": ""
}
} 4. While the reply is written and spoken, send:
{"type": "text", "delta": "..."}as the text comes in;-
for each sentence,
{"type": "say", "text": "..."}, then one binary frame holding that sentence as a WAV; - at the end,
{"type": "done", "text": "<the whole reply>", "cancelled": false}.
5. When the user talks over the reply, the app sends {"type": "cancel"}, and a
new turn cancels the one running too. Stop writing and speaking, and send
done with "cancelled": true and the text so far.
6. A turn that fails sends {"type": "error", "message": "..."} and the call
stays open. Closing the socket ends the call.
9.Limits
- The app waits 15 seconds to connect and 120 seconds for a whole response that is not streamed.
- During an agent turn it waits up to 3 minutes between two events.
- Uploads are at most 100 MB for a video, 25 MB for a PDF or a sound, and 20 MB for a picture.