# Conduck Adapter Contract — v1

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](https://conduck.com/setup/adapter/build.md) 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.

## What you are building

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:

- **The adapter (this contract):** plain HTTP on `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.

## Route 1 — `GET /v1/models`

Purpose: Conduck's "Test Connection" probe, and model listing.

- Requires the same `Authorization` header as chat (below). A missing or wrong token MUST return `401` — never `200`.
- Success: HTTP `200`, `Content-Type: application/json`, body:

```json
{"object": "list", "data": [{"id": "<your-agent-name>", "object": "model"}]}
```

- The top-level `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.
- Every `id` you list MUST be selectable via the chat request's `model` field (below). Don't advertise models you can't serve.

### Capability signaling (optional)

A model entry MAY carry an advisory, namespaced capability object:

```json
{"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"`.
- Absence of the field — or a malformed value — means **unknown**, and the client never assumes text-only from it. Only an explicit `["text"]` tells the client it may warn the user before sending an image.
- In revision 1.3 this field is advisory and reserved: nothing on the wire changes based on it, and the image rules below apply regardless.

## Route 2 — `POST /v1/chat/completions`

The request Conduck sends:

```json
{
  "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`.
- Roles are only `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.
- Preserve part order and part text exactly. If your engine needs one plain string, concatenate the text parts in order with exactly one newline (`\n`) between parts — never reorder, drop, trim, or otherwise normalize part text.
- A message whose only content part is an image is valid — empty or absent text alongside an image is not an error.
- Be lenient about unknown JSON FIELDS: ignore extra object keys anywhere in the body, never reject on them. Unknown content-part TYPES are different — they carry content, so never silently drop one: either reject with `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.
- Conduck sends `Accept: application/json` on both routes — respond with plain JSON regardless of content negotiation.

### Images

- **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 with `400` + code `image_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 `--deep` image probe — the honest decline is a pass; only answering while silently ignoring an image fails.

### Conversation snapshot

- `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.
- Feeding a persistent agent session instead is allowed ONLY when the adapter can prove the session's transcript is exactly a prefix of the incoming `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.

### The response

- Run ONE complete agent turn server-side — including all tool use — and reply only when it is done.
- HTTP `200`, `Content-Type: application/json`:

```json
{
  "choices": [
    {"message": {"role": "assistant", "content": "<final answer as plain text>"}}
  ]
}
```

- Conduck reads exactly `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.
- Return exactly ONE entry in `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.
- NEVER return `tool_calls` — Conduck does not execute tools; your side runs them.
- NEVER stream. No `text/event-stream`, no SSE chunks — one complete JSON body.
- Emit STRICT standard JSON — never `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.

### Timing

- Conduck waits up to 300 seconds per CHAT request and does NOT auto-retry — the user sees a failure with a manual Try Again.
- `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.
- Give the adapter an internal deadline of about 285 seconds, measured from receipt of the complete request body and covering everything after it — queue wait included. On hitting it: cancel the turn, terminate the whole child-process tree, and return `504` (below). Never return a partial answer as a `200` — Conduck would store it as the final reply.

### Errors

Any failure: non-2xx status + a JSON body in the OpenAI error shape, extended with a stable machine-readable `code`:

```json
{"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.

## Authentication

- Require `Authorization: Bearer <token>` on EVERY route, including `/v1/models`, and check it BEFORE doing any other work on the request.
- The token is an opaque byte string: compare it exactly (a constant-time comparison is good practice) and do NOT police its length, charset, or entropy — validation is exact match, nothing else.
- When YOU provision the token, generate at least 32 random bytes (e.g. 64 hex characters). Store it outside the source code (environment variable or file). Never log it.
- Conduck also supports an explicit keyless mode for locked-down private networks, but this contract mandates a token — remove it only if you know exactly why.

## Security floor (non-negotiable)

- Bind to `127.0.0.1` only. Never `0.0.0.0`. Exposure is `conduck-connect`'s job.
- Never interpolate message text into a shell command. Invoke the agent via SDK/library calls, or structured argv plus stdin — no shell parsing of chat content.
- If the agent normally asks for interactive approval before running tools: the adapter is headless — decide a policy up front. Allowlist what is safe; fail closed on everything else. Never solve hanging approvals by auto-approving everything.
- Authenticate BEFORE doing any expensive work. Cap the request body size — but the cap MUST be at least 50 MiB (52,428,800 bytes): the history window plus inline base64 images legitimately arrives in one request, and a small cap breaks image turns invisibly. Output size, tool iterations, queue depth, and child-process lifetime caps are yours to choose — have them.
- Concurrent requests can arrive (two devices, two conversations). If the agent cannot run turns in parallel safely (shared working directory, shared session), serialize with a small queue and return `429` early when a request cannot start in time.
- Never log message content, images, or tokens.

## Self-test (before pairing)

**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):

```bash
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:

```bash
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.

## File lane (optional profile)

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:

- The shared folder is one exact directory — by convention the adapter takes its absolute path from the `CONDUCK_FILES_DIR` environment variable — and the agent is confined to it: it reads inbound files there and writes outbound files there, nowhere else.
- **Inbound:** attached files land under a per-conversation subfolder, and the turn's text names each file's exact path — `- report.pdf (saved as <conversation-id>/<key>__report.pdf)`. Have the agent read them from there.
- **Outbound:** to hand a file back, write it to the working folder's ROOT and name it verbatim in the reply text (e.g. "Saved summary.pdf"). Conduck spots the filename, confirms the file exists, and shows a download chip.
- Common document/data/code extensions are recognized (`pdf`, `csv`, `json`, `md`, `zip`, `png`, `xlsx`, `py`, …); at most 5 files per reply are checked.

## Then connect it

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:

```bash
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).

## Versioning

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.

- **Revision 1.3 (2026-07-18):** the first revision informed by a clean-room adapter build from this document alone. Images — historical images now have a normative rule: forward, or replace in-position with the canonical disclosure; rejecting a request solely for a historical image is no longer permitted (it permanently blocks the conversation). Errors — stable `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.
- **Revision 1.2 (2026-07-16):** Self-test section now leads with `conduck-connect`'s automated `--doctor` check; the manual curl checks stay as the by-hand fallback. No rule changed.
- **Revision 1.1 (2026-07-15):** persistent agent sessions — resume only on a provable prefix match; content-based de-duplication is now forbidden (was recommended). Images — never silently drop an image part; disclose the missing image or return `400` (was: ignore image parts).
- **Revision 1.0:** initial contract.
