caret/v1 · contract 1.1
Implement the Caret API.
Everything the keyboard sends and everything it expects back, in the order
you would build it. Six endpoints; two of them are required. The
machine-readable version of this page is
openapi.yaml — where the two
disagree, the OpenAPI document wins.
Implement GET /v1/health and POST /v1/draft
with typed input, declare
{"draft": true, "dictation": false, "imagine": false}, and
you have a working keyboard. Everything below that is optional and
additive.
Ground rules
| Rule | Detail |
|---|---|
| Base URL | Whatever the user enters in Caret. Paths are appended verbatim, so
https://host/caret means
https://host/caret/v1/health. |
| Transport | https:// is required. Terminate TLS wherever you
like — a reverse proxy or a private tunnel is fine. |
| Versioning | The path carries the version. Additive changes stay in
/v1; anything breaking becomes /v2.
Ignore JSON fields you do not recognise — the keyboard does. |
| Auth | Authorization: Bearer <key> on every endpoint
except health. Compare in constant time. |
| Content type | application/json, except chunk upload, which is
application/octet-stream. |
| Correlation | Every response — success or failure — carries a server-generated
request_id. |
| Client header | Caret sends X-Caret-Client: caret-ios/<version>.
Log it; do not branch on it. |
1 · Health and capabilities
GET /v1/health — no auth
Called on every settings save and periodically while the keyboard is in use. It is the only anonymous endpoint: a user pasting a URL needs to find out whether it is a Caret backend before they have a key.
{
"status": "ok",
"service": "my-backend",
"contract": "caret/v1",
"version": "1.1.0",
"time": "2026-08-06T11:40:20Z",
"auth": {"presented": true, "valid": true},
"capabilities": {
"draft": true,
"dictation": true,
"imagine": false,
"input_modes": {
"draft": ["text", "audio"],
"dictation": ["audio"]
}
}
}
statusisokordegraded.degradedmeans reachable but impaired — the keyboard shows a warning, not a failure.auth.presented/auth.validlet the settings screen tell "wrong key" apart from "wrong URL". With noAuthorizationheader, report{"presented": false, "valid": null}.capabilitiesdrives the UI. Report what you can actually do right now — a surface you advertise and cannot serve fails in the user's hands.capabilities.input_modesis per surface. Omit a surface entirely, or list only["text"], and the keyboard will not offer its microphone there.
2 · Ask
POST /v1/draft
The core surface: an instruction becomes one message, ready to insert. The user sees the result before it lands in the text field, so your job is to return the message itself — no preamble, no quotation marks, no markdown fence.
Request
{
"client_request_id": "5F2A…", // required, ≤128 chars, idempotency key
"input": {"type": "text", "text": "tell Sam I'm running ten minutes late"},
"visible_text": "sam: still on for 3?", // optional, ≤4000, text near the cursor
"app_hint": "com.apple.MobileSMS" // optional, ≤200, soft register hint
}
input is the one place the request says what the user
meant. It is either typed text or a finished audio session:
| Field | Type | Notes |
|---|---|---|
type | "text" | "audio" | Discriminator. Required. |
text | string | Text input only. 1–4000 characters. |
session_id | string | Audio only. A dictation session you have already accepted chunks for. |
client_chunk_count | integer ≥ 1 | Audio only. How many chunks the client uploaded — the completeness check. |
client_total_duration_ms | integer ≥ 1 | Audio only. The client's own measure, advisory. |
polish | boolean | Audio only, default true. Clean the transcript before the agent reads it. |
visible_text is what the keyboard can see near the cursor —
often nothing, and never the whole conversation. Treat it as a register
hint, not as context you can rely on. app_hint is a bundle
identifier; use it to pick a tone, never to gate behaviour.
Response
{
"text": "Hey Sam, I'm running about ten minutes late.",
"status": "complete",
"input_type": "text",
"request_id": "req_414d33"
}
text and request_id are required.
status defaults to complete and is only ever
missing_chunks on the audio path — the one case where
text may be null.
3 · Spoken input
Spoken Ask and spoken Imagine are not a second protocol. The client records
into an ordinary dictation session, uploads ordinary chunks, and then hands
the session id to /v1/draft or /v1/imagine
instead of to the transcript endpoint. One session, one terminal result,
whichever surface claims it.
POST /v1/dictation/sessions → session_id
PUT …/chunks/0, /chunks/1, … → as the user speaks
├── POST …/transcript → Dictate: text
├── POST /v1/draft {input:{type:"audio", …}} → Ask: text
└── POST /v1/imagine {input:{type:"audio", …}} → Imagine: image
Open a session
POST /v1/dictation/sessions
// request
{
"client_request_id": "A17C…",
"codec": "pcm16", // v1 supports pcm16 only
"sample_rate_hz": 16000, // fixed
"channels": 1, // fixed
"intent": "ask", // advisory: dictate | ask | imagine
"app_hint": "com.apple.MobileSMS",
"language_hint": "en-US" // optional, ≤16 chars
}
// response
{
"session_id": "sess_9f2c4d",
"chunk_max_bytes": 524288,
"chunk_target_duration_ms": 3000,
"expires_at": "2026-08-06T12:40:20Z",
"request_id": "req_31c0aa"
}
intent tells you which surface expects to consume the session,
which is useful for warming a model — but it is advisory. Reject it early
only if you genuinely cannot serve that surface at all. Sessions live one
hour; the client opens a new one after that. Replaying a
client_request_id must return the same session rather than
stranding audio in an orphan.
Upload chunks
PUT /v1/dictation/sessions/{session_id}/chunks/{seq}
Content-Type: application/octet-stream
X-Caret-Chunk-SHA256: 3f786850e387550fdab836ed7e6dc881de23001b
X-Caret-Chunk-Duration-Ms: 3000
<raw PCM16, little-endian, 16 kHz, mono>
Chunks are uploaded while the user is still speaking, roughly one every
three seconds, numbered from 0. Each is idempotent per
(session_id, seq), because a phone on a flaky network retries.
Two rules make that safe, and they are worth keeping distinct:
- The body does not match its own
X-Caret-Chunk-SHA256:409 chunk_checksum_mismatch,retryable: true. The transport corrupted it; sending it again may work. - The digest is valid but differs from a chunk you already stored at that
seq:409 chunk_seq_conflict,retryable: false. The client has a bug; retrying cannot fix it.
An identical re-upload is a 200 with
"duplicate": true. Over chunk_max_bytes is
413 chunk_too_large.
{"session_id": "sess_9f2c4d", "seq": 0, "accepted": true,
"duplicate": false, "request_id": "req_88b1c2"}
Finish
POST /v1/dictation/sessions/{session_id}/transcript
// request
{"client_chunk_count": 4, "client_total_duration_ms": 11200, "polish": true}
// response
{
"session_id": "sess_9f2c4d",
"status": "complete",
"text": "Let's push the review to Thursday afternoon.",
"missing_chunks": [],
"duration_ms": 11200,
"request_id": "req_5a09de"
}
client_chunk_count is how the client tells you it is done and
how you detect gaps. If chunks are missing, that is not an
error — answer 200 with a terminal
missing_chunks result so the client can upload exactly what is
absent and call again:
{"session_id": "sess_9f2c4d", "status": "missing_chunks",
"text": null, "missing_chunks": [2], "request_id": "req_5a09de"}
A missing_chunks answer binds the session to that surface but
does not close it — more audio is exactly what it asked for. Only a real
result seals the session; after that, any other surface that presents the
same session_id gets 409 session_conflict.
The same session through Ask
POST /v1/draft
{
"client_request_id": "B92E…",
"input": {
"type": "audio",
"session_id": "sess_9f2c4d",
"client_chunk_count": 4,
"client_total_duration_ms": 11200,
"polish": true
}
}
→ 200
{
"text": "Thursday afternoon works — shall we say 2pm?",
"status": "complete",
"input_type": "audio",
"session_id": "sess_9f2c4d",
"transcript": "let's push the review to thursday afternoon",
"missing_chunks": [],
"duration_ms": 11200,
"request_id": "req_c31f07"
}
Return transcript alongside text. The keyboard
shows the user what it heard, which is how they tell a mis-hearing apart
from a bad answer.
4 · Async work
Anything that may take a while — transcription, generation — uses one
convention across every endpoint. Answer 202 while the work is
running:
HTTP/1.1 202 Accepted
Retry-After: 3
{"status": "in_progress", "session_id": "sess_9f2c4d",
"started_at": "2026-08-06T11:40:20Z", "elapsed_seconds": 3.2,
"retry_after_seconds": 3, "request_id": "req_c31f07"}
The client polls by re-POSTing the identical body. There is no job id and no second endpoint: the request is the handle. That puts three requirements on you.
request_idis stable from the first202through the terminal response. It identifies the work, not the HTTP round trip.- Re-posting never starts a second job. Register the job and check for an existing one under one lock, or a client that polls twice pays twice.
- Terminal results are cached — successes and failures both. A failed generation that silently re-runs on the next poll is a surprising bill.
Doing the work synchronously is fine if you can finish inside the client's
timeout; 202 is a tool, not an obligation.
5 · Imagine
POST /v1/imagine — optional
Same input model as Ask: {"type": "text"} or
{"type": "audio"}, the latter consuming a dictation session
exactly as above. If you do not implement it, report
"imagine": false in capabilities and answer
404 not_found.
// request
{
"client_request_id": "C40D…",
"input": {"type": "text", "text": "a lighthouse at dusk, watercolour"},
"aspect_ratio": "square", // square | landscape | portrait
"quality": "standard" // fast | standard | best
}
// response
{
"status": "complete",
"input_type": "text",
"media": {
"kind": "image",
"mime_type": "image/png",
"filename": "caret-imagine.png",
"byte_length": 184320,
"sha256": "9f86d081884c7d65…",
"inline_base64": "iVBORw0KGgo…"
},
"provider": "my-image-tool",
"request_id": "req_77af10"
}
byte_length and sha256 are required and must
describe the bytes you are actually delivering. Return the image as
inline_base64, or as a data_url, or as a URL the
client can fetch — pick one.
6 · Idempotency
Every mutating request carries a client-generated
client_request_id (≤128 characters). Replaying one must return
the original result without re-running the work. The keyboard retries on
connection drops, and a user who sees a network error and taps again should
not be charged for two generations.
Cache successes. Whether you cache failures is your call for the
synchronous path — but for a 202 job the answer is yes, or
polling turns every failure into an infinite retry loop.
7 · Errors
Every non-2xx response, on every endpoint, is exactly this shape:
{
"error": {
"code": "session_conflict",
"message": "session already consumed by dictation",
"retryable": false
},
"request_id": "req_2bdad6"
}
message is shown to the user verbatim in the status row, so
keep it short and free of internals. Never let a traceback or an HTML error
page reach the wire — a client that cannot parse your error has no way to
behave sensibly.
| Status | Codes |
|---|---|
| 400 | bad_request |
| 401 | unauthorized |
| 404 | not_found, unknown_session |
| 409 | chunk_checksum_mismatch, chunk_seq_conflict, session_conflict |
| 410 | session_expired |
| 413 | chunk_too_large |
| 415 | unsupported_audio_codec |
| 422 | input_invalid, instruction_invalid, prompt_invalid, unsupported_input_type, unsupported_aspect_ratio, unsupported_quality, audio_too_short, audio_too_quiet, no_speech_detected |
| 429 | rate_limited |
| 500 / 503 | internal_error, transcription_failed, image_generation_failed |
| 504 | draft_timeout |
The list is append-only. Clients meeting an unknown code fall back to
retryable, so set that field truthfully — it is the only thing
a future client can rely on.
8 · Backwards compatibility
Contract 1.0 sent the instruction as a bare string. Both spellings are still accepted:
{"instruction": "say hello"} // 1.0, still valid
{"input": {"type": "text", "text": "say hello"}} // 1.1
{"prompt": "a lighthouse"} // 1.0 imagine alias
Accept exactly one. Both together, or neither, is
422 input_invalid — guessing which one the client meant is
worse than a clear error. If you are writing a backend today, implement
input and accept the aliases for older clients; the aliases
keep their 1.0 error codes (instruction_invalid,
prompt_invalid) so existing clients keep matching on them.
9 · Security posture
- TLS is not optional. The keyboard refuses plain HTTP. Terminate TLS in a proxy or tunnel; your app process can stay on loopback.
- Fail closed. No keys configured means every
authenticated request is
401, not "open to everyone". - Constant-time key comparison, and never log the key —
log the
request_idinstead. - One key per device makes revocation a matter of removing one string.
- Audio is the sensitive part. Delete chunks as soon as a session reaches a terminal result, and expire whole sessions on a timer. Recordings that outlive their transcript are a liability with no upside.
- Bound your inputs. Enforce the size limits above
rather than trusting
Content-Length, and reject session ids that could escape your storage directory. - Treat
visible_textas untrusted. It is text from someone else's message and may try to instruct your agent. It is context for register, not a command. - Ask is read-only. The user has not inserted anything yet. A draft that books the meeting it is describing is a side effect nobody approved — say so in your prompt, every time.
10 · Check yourself
The reference repository ships a conformance checker: standard-library Python, no knowledge of any particular implementation, so it works against a backend written in any language.
python3 reference-backend/conformance.py \
--base-url https://your-host --api-key "$CARET_API_KEY"
It exercises the parts that are easy to get subtly wrong — the one input
model, chunk idempotency, checksum rejection, stable
request_id across polls, one terminal result per session — and
skips what your capabilities say you do not implement. Exit code 0 means
the keyboard will be happy.
A first smoke test by hand:
curl -s https://your-host/v1/health | python3 -m json.tool
curl -s -X POST https://your-host/v1/draft \
-H "Authorization: Bearer $CARET_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"client_request_id":"smoke-1",
"input":{"type":"text","text":"tell Sam I am running ten minutes late"}}' \
| python3 -m json.tool
11 · The reference backend
If you would rather not write one, the repository includes a complete
caret/v1 backend in about a thousand lines of Python with no
dependencies. It is meant to be read as much as run: every rule on this
page is implemented once, in an obvious place.
cd reference-backend
export CARET_API_KEYS="$(python3 -c 'import secrets;print(secrets.token_urlsafe(32))')"
echo "$CARET_API_KEYS" # paste this into Caret
python3 -m caret_backend --port 8787
python3 -m unittest discover -s tests # 53 hermetic tests, no network
python3 conformance.py --base-url http://localhost:8787 --api-key "$CARET_API_KEYS"
Your agent plugs in as a command line — anything that takes a prompt and prints a reply on stdout:
export CARET_AGENT_COMMAND='my-agent --quiet {prompt}' # or stdin, if no {prompt}
export CARET_STT_COMMAND='whisper-cli -m model.bin -f {audio} --no-timestamps'
export CARET_IMAGE_COMMAND='my-image-tool --prompt {prompt} --out {out}'
Dictation and Imagine stay off — and say so in capabilities —
until you configure those two. Full configuration, TLS notes and the
per-agent details are in the reference backend's own README.