A normative specification, written to be handed to an AI coding tool. If you built your own AI agent and want the Conduck app to talk to it, paste this page (or its URL) into the tool that built your agent — e.g. Claude Code.
Prefer hands-off? There's a step-by-step build brief for AI tools — workflow, verification, and pairing included. Build it with your AI
Status: v1, stable — document revision 1.3. This page is a normative specification, written to be handed to an AI coding tool. If you built your own AI and want the Conduck app to talk to it, paste this page (or its URL) into the tool that built your agent — e.g. Claude Code. There is also a step-by-step build brief written for AI tools that wraps this contract in a full workflow.
The key words MUST, MUST NOT, SHOULD, and MAY mark binding requirements and recommendations, in their usual (RFC 2119) sense. Everything else is explanation.
Conduck is a native client — iPhone, iPad, Mac, Apple Watch, CarPlay, and Android — for an AI you run yourself. It speaks the OpenAI chat-completions HTTP format. Your agent stays exactly as it is — you add a small adapter in front of it: a separate HTTP service that receives Conduck’s request, runs ONE complete turn of your agent, and returns the final answer.
Division of labor — important:
127.0.0.1, two routes, bearer auth. No TLS.conduck-connect (Conduck’s setup script): HTTPS, network exposure (Tailscale, Cloudflare Tunnel, or an HTTPS front you already run — self-signed certificates get pinned automatically), and pairing with the app. The script installs nothing and never creates the TLS listener itself. The adapter must NOT handle TLS and must NOT bind to public interfaces.The listening port is yours to choose — this contract fixes the loopback binding, not the port. Examples in this document use 8480.
Route requests by URL path; ignore any query string. Before building: inspect the existing project — don’t guess. If it already runs a long-lived HTTP server implementing the two routes below, nothing needs to be built.
GET /v1/modelsPurpose: Conduck’s “Test Connection” probe, and model listing.
Authorization header as chat (below). A missing or wrong token MUST return 401 — never 200.200, Content-Type: application/json, body:{"object": "list", "data": [{"id": "<your-agent-name>", "object": "model"}]}
data key MUST be a JSON array — Conduck’s probe requires exactly that envelope. The object fields are optional; data is not. One entry naming your agent is enough.id you list MUST be selectable via the chat request’s model field (below). Don’t advertise models you can’t serve.A model entry MAY carry an advisory, namespaced capability object:
{"id": "<your-agent-name>", "object": "model", "conduck": {"input_modalities": ["text", "image"]}}
input_modalities is an unordered list of the input kinds the model accepts: "text", "image".["text"] tells the client it may warn the user before sending an image.POST /v1/chat/completionsThe request Conduck sends:
{
"messages": [
{"role": "user", "content": "..."},
{"role": "assistant", "content": "..."},
{"role": "user", "content": "..."}
],
"stream": false,
"model": "<name>"
}
messages MUST be non-empty, and its FINAL element is always the current turn, always with role user — that is a promise about what Conduck sends. A request that violates it is malformed; the adapter MAY reject it with 400.user and assistant.model is OPTIONAL — present only if the user set one in the app. A request without model MUST work (serve your default). A request with model MUST select that advertised model — if the value matches nothing you listed on /v1/models, return 400 with code model_not_found (note: this deliberately diverges from OpenAI’s 404 — here 404 means unknown path, nothing else). An adapter that advertises exactly ONE model id MAY ignore the field.content is EITHER a plain string OR an array of parts: {"type":"text","text":"..."} and {"type":"image_url","image_url":{"url":"data:image/jpeg;base64,..."}}. Images are always inline base64 data-URIs, never remote URLs. Handle both shapes.\n) between parts — never reorder, drop, trim, or otherwise normalize part text.400 or replace it in position with an explicit note that an unsupported attachment was omitted.stream is always false in Conduck’s requests. If any request carries stream: true anyway, MUST still answer with the single synchronous JSON response below — never an SSE stream, and never a rejection just because of the flag.Accept: application/json on both routes — respond with plain JSON regardless of content negotiation.Current turn (the final messages element): if the effective engine accepts images, the adapter SHOULD forward them. It MUST NOT silently discard one — the forbidden middle is answering as if no image existed. An adapter whose engine cannot accept images MAY reject the request with 400 and code image_unsupported.
Earlier messages: for every valid image_url part in a message BEFORE the final one, the adapter MUST either forward the image to the engine or replace that part, in the same position, with this exact disclosure text:
An image was attached in this earlier message, but this adapter cannot inspect it. Do not infer its contents.
The adapter MUST NOT reject a request merely because an EARLIER message contains an image. Conduck resends the conversation history with every request — an adapter that rejects on historical images permanently blocks every later turn of that conversation, including plain-text ones.
The reply does not need to mention historical images. The in-position disclosure gives the engine what it needs; per-reply disclaimers are noise.
Text-only engines — the complete recipe. An engine that cannot read images is a first-class, fully conformant configuration, not a degraded fallback. Do all three: (1) advertise no image capability on
/v1/models; (2) reject a CURRENT-turn image with400+ codeimage_unsupported; (3) replace EARLIER-message images with the in-position disclosure text above (never reject those requests). An adapter doing exactly this passes the doctor’s--deepimage probe — the honest decline is a pass; only answering while silently ignoring an image fails.
messages is the authoritative, ordered snapshot of the conversation — a sliding window of the most recent turns (currently the last 40), not an unbounded full transcript. Older image-bearing turns arrive as plain-text file references instead of inline image bytes. Preserve roles and order, and run each request as a fresh, self-contained agent conversation — the adapter may be fully stateless.messages and carries no extra conversational context; on any mismatch or ambiguity, start fresh. Do NOT de-duplicate by message content — the window slides and image turns change shape as they age, so content matching silently corrupts the conversation.200, Content-Type: application/json:{
"choices": [
{"message": {"role": "assistant", "content": "<final answer as plain text>"}}
]
}
choices[0].message.content (a string). Everything else is ignored. Missing choices, missing content, or non-string content is a decode error shown to the user.choices. The array decodes strictly: a malformed extra entry (e.g. a content: null second choice) fails the WHOLE reply, even when choices[0] is fine.content must be a NON-EMPTY string. If the engine produced no text, return a 5xx error (below) — never a blank 200: Conduck accepts "" as a valid, blank final answer and stores it.tool_calls — Conduck does not execute tools; your side runs them.text/event-stream, no SSE chunks — one complete JSON body.NaN or Infinity anywhere in the body. Python’s json.dumps allows them by default; Conduck’s strict client-side parsers reject the entire response as invalid JSON.GET /v1/models is different: answer it within 15 seconds — the app’s Test Connection gives up after that. Keep the route instant; never lazy-load a model or cold-start anything on this path.504 (below). Never return a partial answer as a 200 — Conduck would store it as the final reply.Any failure: non-2xx status + a JSON body in the OpenAI error shape, extended with a stable machine-readable code:
{"error": {"message": "<short reason>", "type": "<kind>", "code": "<stable-code>"}}
error.message is free prose for your logs — it never reaches the user. error.type is the coarse OpenAI-style kind (e.g. invalid_request_error, server_error). code is the field clients key on; include it whenever one below fits, omit it otherwise. Values outside this vocabulary are treated as absent.code | Meaning | Status |
|---|---|---|
image_unsupported | Current turn carries an image this adapter cannot accept | 400 |
model_not_found | Supplied model matches no advertised id | 400 |
context_too_long | Conversation exceeds what the engine can accept | 400 |
image_too_large | An image exceeds a size cap | 413 |
overloaded | Busy — a turn cannot start in time | 429 or 503 |
upstream_timeout | The engine hit the internal deadline | 504 |
upstream_failure | The engine crashed or produced no usable result | 502 |
Status codes are normative:
| Status | When |
|---|---|
400 | Malformed body or fields — invalid JSON, empty messages, bad roles or shapes, plus the 400-coded cases above |
401 | Missing or wrong token — checked FIRST, before any body processing |
404 | Unknown path |
405 | Known path, wrong method |
413 | Request body exceeds your cap (see the floor below) |
429 / 503 | Busy or overloaded |
502 | Engine failure |
504 | Engine timeout |
HTTP-framing edge cases — a missing Content-Length, chunked request bodies — are deliberately NOT specified: let your HTTP stack handle them however it does. Conduck shows its own user-facing text keyed to the status and code — pick both carefully.
Authorization: Bearer <token> on EVERY route, including /v1/models, and check it BEFORE doing any other work on the request.127.0.0.1 only. Never 0.0.0.0. Exposure is conduck-connect’s job.429 early when a request cannot start in time.Recommended check. On the machine where the adapter runs — here listening on port 8480, with TOKEN set — run these two commands (if your adapter binds a different port, change the URL in every command here to match; 8480 is only this document’s example):
curl -fsSLO https://github.com/gigaduckai/conduck-connect/releases/latest/download/conduck-connect.sh
CONDUCK_TOKEN="$TOKEN" bash conduck-connect.sh --doctor http://127.0.0.1:8480
The script changes nothing. It shows what passed and what to fix; green checks mean the tested requests and replies match what Conduck expects. To also check image handling and the deeper probes, run the second command once more with --deep added at the end.
Check by hand. These three commands show the replies directly, but cover only the basics:
curl -s -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8480/v1/models
# prints: {"object":"list","data":[{"id":"...","object":"model"}]}
curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8480/v1/models
# prints: 401
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"messages":[{"role":"user","content":"Reply with exactly: pong"}],"stream":false}' \
http://127.0.0.1:8480/v1/chat/completions
# prints: {"choices":[{"message":{"role":"assistant","content":"pong"}}]}
The pong round-trip mirrors what conduck-connect’s verify step runs.
Beyond chat, Conduck can exchange files with your agent through a folder on the adapter’s machine, served over WebDAV — conduck-connect offers to set this up and calls the folder “the agent’s working folder”. This lane is a deliberately OPTIONAL profile outside the core contract: skipping it is fine (chat works and attachments simply ride inline), and enabling it changes nothing about the two routes above. If you enable it:
CONDUCK_FILES_DIR environment variable — and the agent is confined to it: it reads inbound files there and writes outbound files there, nowhere else.- report.pdf (saved as <conversation-id>/<key>__report.pdf). Have the agent read them from there.pdf, csv, json, md, zip, png, xlsx, py, …); at most 5 files per reply are checked.On the machine where the adapter runs, run Conduck’s setup script with the --generic flag — https://conduck.com/setup/adapter/v1/ and the app’s guided setup for a custom gateway show the exact one-paste command:
curl -fsSLO https://github.com/gigaduckai/conduck-connect/releases/latest/download/conduck-connect.sh && bash conduck-connect.sh --generic
The flag matters: it skips gateway auto-detection, so another AI install on the machine can’t become the default. Give the script the adapter’s local port. It helps expose the adapter over HTTPS — through a Tailscale or cloudflared already on the machine, or an HTTPS front you already run; it installs nothing and never creates the TLS listener itself (with none of those present, Tailscale is the one-command unblock) — then verifies with real requests and prints a QR pairing code to scan in Conduck.
The wizard needs an interactive terminal: prompts cannot be piped, and there are no non-interactive answer flags. An AI tool driving it needs a real PTY (or hand the final pairing step to a human at a terminal).
This is version 1 of the contract — document revision 1.3. This URL is stable. Before Conduck’s public launch, normative corrections may revise this document in place, always with a visible changelog entry below. After launch, any change that would make a conforming adapter nonconforming ships as /setup/adapter/v2 — v1 stays; pure clarifications may continue to land here.
code vocabulary added to the envelope; status codes are now normative. New normative text for model selection, stream: true tolerance, conversation edge cases, unknown content-part types, text-part flattening, and the exact 50 MiB body floor. Capability signaling (conduck.input_modalities) reserved as advisory. Platform-neutral wording (the client also runs on Android). Examples now use port 8480.conduck-connect’s automated --doctor check; the manual curl checks stay as the by-hand fallback. No rule changed.400 (was: ignore image parts).