Caret mark Caret Docs

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.

Shortest path

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

RuleDetail
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"]
    }
  }
}

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:

FieldTypeNotes
type"text" | "audio"Discriminator. Required.
textstringText input only. 1–4000 characters.
session_idstringAudio only. A dictation session you have already accepted chunks for.
client_chunk_countinteger ≥ 1Audio only. How many chunks the client uploaded — the completeness check.
client_total_duration_msinteger ≥ 1Audio only. The client's own measure, advisory.
polishbooleanAudio 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:

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.

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.

StatusCodes
400bad_request
401unauthorized
404not_found, unknown_session
409chunk_checksum_mismatch, chunk_seq_conflict, session_conflict
410session_expired
413chunk_too_large
415unsupported_audio_codec
422input_invalid, instruction_invalid, prompt_invalid, unsupported_input_type, unsupported_aspect_ratio, unsupported_quality, audio_too_short, audio_too_quiet, no_speech_detected
429rate_limited
500 / 503internal_error, transcription_failed, image_generation_failed
504draft_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

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.