Setup Adapter contract

Conduck Adapter Contract

v1 · stable

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.

Raw markdown for your AI conduck.com/setup/adapter/v1.md

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.10. 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.

What you are building

Conduck is a native Apple client — iPhone, iPad, Mac, Apple Watch, and CarPlay — for an AI you run yourself. An Android client is a work in progress, not yet a compatibility authority or a production-ready client. Conduck speaks the OpenAI chat-completions HTTP format — the request and response SHAPES, not the streaming transport. Every reply is one complete JSON body; there is no SSE path anywhere in this contract, in either direction. If you are about to reach for your HTTP framework’s streaming helper because the format’s name is familiar, read the stream rule under Route 2 first. Your agent keeps its code, its home, and its tools — you do not port it, rewrite it, or move it anywhere. 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. What often does have to change is your agent’s CONFIGURATION, because the adapter runs it headless: an agent that normally asks permission before each tool call needs a policy decided up front, and one that normally answers in an editing or patch format needs an output mode that returns a final answer. Both are covered below.

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 — the certificate must be one the device already trusts; conduck-connect cannot make a self-signed certificate trusted, and does not try), 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:
{"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:

{"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.
  • This field is advisory and reserved throughout v1: 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:

{
  "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). What a PRESENT model obliges depends on how many ids you advertise, and the two cases are separate rules, not a rule with a footnote:
    • Two or more advertised ids: a supplied model MUST select that advertised model, and a value matching nothing you listed MUST return 400 with code model_not_found (note: this deliberately diverges from OpenAI’s 404 — here 404 means unknown path, nothing else).
    • Exactly one advertised id: you MAY ignore the field entirely, unknown values included. With one thing on the menu nothing is ambiguous. This REPLACES the rule above for you rather than sitting beside it, and the conformance check applies the same exemption.
  • The id you advertise is the selector Conduck sends back to you. It need not be what your engine calls that model — where the two differ, map yours onto the engine’s before you invoke it. Handing your own advertised id straight to an engine that never heard of it is the common first-build failure, and a confusing one to diagnose: every request the app sends with a model selected fails, while requests without one keep working.
  • 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. (One narrow exception, for an EMPTY text part beside an image, is licensed two bullets below — nothing else is.)
  • That rule governs the parts WITHIN a message. Turning the message ARRAY into whatever your engine actually takes — a native chat-template array, a labelled transcript, one synthesized prompt — is your own integration decision, and this contract deliberately does not pin a format: nothing about it crosses the wire. Two things are worth knowing before you invent one. Everything you wrap around the user’s words is visible to the engine and competes with them. And the self-test below sends a bare Reply with exactly: pong — if your framing stops that from producing exactly pong, it is too heavy. If your engine has a native multi-turn input, prefer it, but check that it round-trips roles and content faithfully; some agent CLIs’ own history parsers do not.
  • A message whose only content part is an image is valid — an image-only turn arrives with an empty text part beside the image, and empty or absent text alongside an image is not an error. Some engines refuse to start a turn on empty text; when yours does, the adapter MAY supply a minimal neutral carrier in the empty text’s place — this exact sentence, and nothing more: The user sent an image with no accompanying text. This is the one licensed exception to the preservation rule above, and it reaches no further: it substitutes for a text part that is empty or absent, only on an engine that refuses the empty prompt, and never rewrites text a person actually wrote. Keep it stable the way the earlier-image disclosure below is fixed; never invent per-turn framing to fill the gap.
  • 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 (a short fixed sentence such as An unsupported attachment was omitted. does the job — keep whatever you choose stable, so it reads the same across replies, the way the earlier-image disclosure below is fixed). That replace-with-a-note option exists for unknown part types only — image_url is a KNOWN type, and a current-turn image may never be turned into a note (see Images below).
  • 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): the adapter MUST either forward every image_url part to the effective engine, or REJECT the whole request — with 400 and code image_unsupported when the engine cannot accept images at all, or with the size refusal the errors table already defines (413 + image_too_large) when one exceeds a cap. An adapter whose engine accepts images SHOULD forward rather than reject. What never conforms is ANSWERING: a 200 produced without the engine seeing the image is the forbidden move however it is worded — silently, or with a substituted in-position note — because the user reads it as a confident answer about their photo. In-position replacement belongs to EARLIER messages only (below).

  • 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.

  • The forward-or-disclose choice for earlier messages is independent of whether the engine has vision. An engine whose framework can only attach images to the CURRENT turn — the ordinary shape for agent frameworks — may forward current-turn images and use the disclosure for earlier ones. That is a first-class configuration, not a degraded one; what never conforms is the current-turn 200 without a sighting.

  • An image_url whose url is not an inline data: URI is a malformed part: the adapter SHOULD reject the request with 400, and MUST NOT dereference the URL. Conduck never sends remote URLs — one can only arrive from a proxy or a hand-made request — and an adapter that dereferences it turns chat content into server-side requests aimed wherever the URL says.

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) if you signal capabilities on /v1/models at all — the field stays optional — signal "conduck": {"input_modalities": ["text"]}, because leaving it out means unknown, not text-only, so silence cannot say this for you; (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 --check-adapter --deep image probe — the honest decline is a pass; what fails is a 200 the engine produced without seeing the image, disclaimered or not.

Engines with no image channel of their own — how to forward anyway. Many capable engines, terminal coding agents especially, have no inline image input at all; their only route to a picture is a file-reading tool pointed at a path, or a separate attachment argument on the command line. Forwarding through such a channel is fully conformant — you do NOT have to decline. Write the decoded image to a file the engine can read and name that path where the engine will act on it — a per-turn temp directory is the cleanest home (the optional file lane’s working folder also works when the engine is confined to it); either way the file is per-turn scratch: delete it when the turn ends, on success and failure alike, and never let decoded photos accumulate. Two things to know before you rely on this route. The part-order rule above binds the TEXT parts, so forwarding an image through a separate attachment channel is conformant even though the image’s interleaved position among the text is lost — position may be lost, the image may not: every current-turn image must still reach the engine, exactly as the rules above require. And “the engine accepts images” is not “this engine looked at them” — some frameworks quietly turn an attached image into a text description instead of sending the real pixels, which the --deep probe reports as UNVERIFIED; confirm the actual image bytes reach the model before trusting the path.

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. The adapter MUST preserve roles and order, and MUST run each request as a fresh, self-contained agent conversation unless the exception below applies — it may be fully stateless.
  • Consecutive messages MAY carry the same role. Two user messages in a row is a real shape, not a malformed request: Conduck stores a turn as soon as it is sent, so a turn whose reply never arrived stays in the conversation and the next turn is appended after it. The adapter MUST accept that, and MUST NOT drop or reorder messages to make the roles alternate. If your engine’s chat format requires strict alternation (user, assistant, user, …), bridging it is the adapter’s job — the same array-to-engine mapping the request rules already leave to you: combine each run of same-role messages into one message of that role, in order, keeping every part. --check-adapter sends this shape in both profiles: an image-bearing user message followed by a text-only user one.
  • Feeding a persistent agent session instead is allowed, but only on an exact match, and exactly one test establishes one: the session’s COMPLETE prior transcript MUST be structurally identical, in order, to the leading elements of the incoming messages — same roles, same string-or-parts shape, same part order, same values. (Structural, not textual: JSON whitespace and object-key order are irrelevant.) It is all of it or none of it: the adapter MUST NOT accept partial credit, hunt for individual matching messages, or match on suffix overlap. On any mismatch, on extra conversational context the session carries that messages does not, or on any ambiguity at all, it MUST start fresh.
  • That whole-transcript comparison is a different operation from de-duplicating by message content, which remains forbidden: the adapter MUST NOT decide which messages are “new” by matching them individually. The window slides and image turns change shape as they age, so per-message matching silently corrupts the conversation.
  • Two ordinary events end a session even when everything matched last time: the window sliding past its oldest turn, and an image turn ageing into a plain-text file reference. Both are the fail-safe working, not a fault. And note what a stored copy of your own outbound messages actually proves — only what you SENT. An adapter whose engine can compact, summarize, or inject per-session context of its own cannot know what the live session holds, and MUST NOT use this exception.
  • v1 carries no conversation or session identifier. messages IS the identity of the conversation, and there is nothing else to key server-side state on — the adapter MUST NOT manufacture one from message content, the bearer token, file paths, or the transport connection. (The <conversation-id> folder name in the optional file lane below, and the per-dispatch outputs folder that lane introduces, are storage locations rather than protocol identifiers. Both reach you only as ordinary text inside a message, both fall under the file-paths prohibition above, and neither may key server-side state — the outputs folder is minted fresh for every dispatch, so anything keyed to it would be thrown away a turn later anyway.)

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:
{
  "choices": [
    {"message": {"role": "assistant", "content": "<final answer as plain text>"}}
  ]
}
  • Conduck reads choices[0].message.content (a string) and nothing else decides whether the reply is accepted. Missing choices, missing content, or non-string content is a decode error shown to the user. A few optional fields are read when they happen to be there — see “Optional response metadata” below — and none of them can turn a good reply bad.
  • 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.
  • content is what the user reads — and on a watch or in a car, what gets read aloud. It is the answer, not the work. An engine whose default output mode emits patch hunks, SEARCH/REPLACE blocks, or a running tool log produces a perfectly conformant reply that is unusable on those surfaces; configure ordinary turns to return its final answer instead.
  • An agent engine can finish a turn having DONE the work and said nothing — the turn ends on a bare tool call, or an empty final message, while the file it was asked for sits correctly on disk. There is still no answer to return: obtain real words from the engine or return 502. The adapter should never author the answer itself — whatever it returns is presented to the person as the assistant speaking.
  • If your engine renders tool calls or reasoning INTO its message text — @save(call_abc): {...} lines, <think> blocks, a transcript of the work — that reply passes every check and is still the wrong thing to send: strip the machinery and return the last message that still contains an ANSWER, not simply the last message. Prefer your framework’s structured final-result field, where it has one, over pattern-matching message text. No conformance check can catch this (any non-empty string passes); how it surfaces is a watch reading @complete(call_x): {} aloud.
  • Check the engine’s own success signal, not merely that text came back. Several frameworks report a failed model call as a completed run whose text is the exception string — a well-formed success record reading No connected db. Returned as a 200, that error reaches the person as a confident answer; map it to 502 instead.
  • 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. This bans streaming TO Conduck, not streaming inside your adapter: an engine that only streams is entirely fine — consume its stream yourself, then send the finished text in one 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. Parse strictly on the way IN too: some parsers — Python’s json.loads included — ACCEPT NaN and Infinity by default, so a lenient parse plus any value you reflect back reproduces exactly the body this rule bans, from a request that looked fine.

Optional response metadata

Conduck MAY observe four things a reply can carry beyond the answer itself: top-level model and id, usage with prompt_tokens / completion_tokens / total_tokens, and choices[0].finish_reason. It uses them to show the person a private account of how their own setup is behaving, on their device and nowhere else.

  • All four are OPTIONAL and stay optional. Omitting every one of them is fully conformant, changes nothing about how a reply is accepted, and needs no re-pairing. Nothing on the wire depends on them.
  • A field that is present but malformed is ignored on its own — it never sinks the reply or the fields beside it. Report a number only when you actually have it; a placeholder zero is read as a real measurement.
  • The usage counts must be non-negative integers, written as JSON integers. A negative count, or one written with a decimal point or an exponent — 7600.0 and 7.6e3 included — is treated as absent, so a count accumulated in a float needs rounding to a whole number before you send it.
  • If you report usage, it SHOULD account for the WHOLE turn — every model call your agent made while answering it, tool loops and sub-agents included — not just the last one. A figure covering one call out of several is worse than no figure at all, because nothing on the client can tell the two apart.
  • If the engine stopped because it ran into an output limit, you SHOULD say so with finish_reason: "length". It is the only way the person can be told their answer was cut off rather than finished — and returning the truncated text with no signal makes a cut-off reply look like a considered one. An agent loop that stopped at its own step cap counts WHEN the cap is what ended the answer — a limit ran out before the answer was finished, which is exactly what length exists to say. A cap that merely closed the loop after a complete answer was already given is not a truncation; report nothing there.

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. It must stay answerable while a chat turn is running — normally that means serving it from a static list rather than through the queue your turns wait in, because an adapter that serializes everything through one queue makes Test Connection wait behind a five-minute agent turn and fail for no reason.
  • 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.
  • Reading the body is deliberately OUTSIDE that deadline, so that a slow upload cannot eat the agent’s budget — which means it needs a control of its own. You SHOULD apply a separate read/idle timeout to the body transfer itself; without one, a peer that opens a connection and then dribbles or stalls holds it open indefinitely and never reaches any deadline you set. Keep that timeout generous: a legitimate request carrying inline images can approach the 50 MiB floor below over a slow mobile link, and cutting it short breaks real image turns. What you answer a stalled upload — if you can answer at all — is not specified.

Errors

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>"}}
  • That body MUST carry error.message and error.type, both non-empty strings. error.message is free prose — never shown to the user verbatim, but not dead weight either: when an error carries no recognized code, the app may fall back to reading the message to work out what went wrong, so write it as if something will parse it. 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. The conformance check grades the two required fields strictly where the app depends on them: a decline carrying image_unsupported or model_not_found fails outright when either is missing or empty, right status and right code notwithstanding.
codeMeaningStatus
image_unsupportedCurrent turn carries an image this adapter cannot accept400
model_not_foundSupplied model matches no advertised id400
context_too_longConversation exceeds what the engine can accept400
image_too_largeAn image exceeds a size cap413
body_too_largeRequest body exceeds your cap — no claim about what it contains413
overloadedBusy — a turn cannot start in time429 or 503
upstream_timeoutThe engine hit the internal deadline504
upstream_failureThe engine crashed or produced no usable result502

Status codes are normative:

StatusWhen
400Malformed body or fields — invalid JSON, empty messages, bad roles or shapes, plus the 400-coded cases above
401Missing or wrong token — checked FIRST, before any body processing (then see the connection rule below)
404Unknown path — but an unauthenticated request to an unknown path is a 401, since auth is checked before routing
405Known path, wrong method
411OPTIONAL — a body-bearing chat request that declares no Content-Length (see the paragraph below the connection rule); never for GET /v1/models, which has no body. No code is defined for this case
413Request body exceeds your cap (see the floor below). A 413 refused straight from Content-Length cannot know whether an image is responsible — body_too_large is the honest code there; image_too_large belongs to an image measured against a per-image cap
429 / 503Busy or overloaded
502Engine failure
504Engine timeout

Rejecting before you have read the body

Checking the token first is right — you should never read a 50 MiB body from an unauthenticated peer. But a response sent while that body is still unread leaves it sitting on the connection, and HTTP/1.1 connections get REUSED: the peer’s next request is then parsed starting in the middle of your leftovers.

So whenever you answer before consuming the whole body — 401, 404, 405, 413, an early 400, an overloaded 429 or 503 — you MUST NOT let another request be read from that connection unless you first read and discard the remainder. If you will not read it, send Connection: close on the response and close the connection after it.

The simplest fully conforming policy is to never reuse a connection at all: send Connection: close on EVERY response. Nothing in this contract needs keep-alive, and builds that chose this have passed the reuse probe through a real pooling tunnel. Treat connection reuse as an optimization you may decline, not an obligation you must get right.

  • For 401 and 413, closing is usually the better half of that choice: draining a body from an unauthenticated peer, or one you just rejected for exceeding your cap, performs exactly the work the rejection existed to avoid.
  • When you close, still read a little of what is in flight first, briefly, so the peer can finish writing and actually read your status line instead of a connection reset.

Your own by-hand checks cannot see this — every curl command opens its own connection. --check-adapter includes a counted desync probe (AUTH_CHAT_REJECT_BODY sends the shapes that reuse corrupts) — but it exercises the 401 path only, and its green means “no follow-up failure observed”, never “every early-rejection path proven”: the 404/405/413/early-400 paths are not probed at all. Wherever no probe reaches, a mistake surfaces only behind the pooling HTTPS front you will actually deploy — Cloudflare Tunnel, Tailscale Serve, nginx — as unexplained failures on requests that are themselves perfectly fine. Assume nothing is doing this for you: Python’s http.server never drains, and avoids the corruption only while it is left in its default non-persistent HTTP/1.0 mode — which, per the paragraph above, is a fine place to leave it; its whole recipe is three lines (keep HTTP/1.0 mode or set close_connection = True, emit Connection: close, and read briefly from the in-flight body before closing so the peer sees your status line).

Conduck’s chat POSTs always carry a body of known size, so Content-Length is present on them. GET /v1/models has no body and no Content-Length — never refuse it on that basis. A BODY-BEARING request that declares no length — a chunked body, typically a proxy re-framing rather than Conduck itself — MAY be refused outright (411, or a 400); handling it is equally fine. Let your HTTP stack decide, and note this resolves an apparent tension elsewhere: the hardening advice to refuse oversized bodies “from Content-Length before reading” cannot apply to a body that declares no length, and refusing such a body is the conforming way out. Conduck shows its own user-facing text keyed to the status and code — pick both carefully. (One honest caveat on body_too_large: current Conduck clients render any 413 with their image-too-large text, so today the code buys accuracy in logs and for future clients, not yet in what the user reads.)

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.
  • Watch for engines that expand file references INSIDE the prompt text. Some agents treat a @path token (or an {{file}}-style include) in the message as an instruction to read that file and splice its contents in before the model sees them. Structured argv does not stop this, because the expansion happens inside the text you pass, not in a shell — so ordinary chat content can read any file the engine can reach. If your engine has such a feature, disable it. If it genuinely cannot be disabled, defeat it only in a way the model never sees — an escaping the engine strips back out before inference; anything that changes the text the model actually reads breaks the part-text preservation rule above. And when it can be neither disabled nor invisibly defeated — both routes are closed on some engines — confinement is the mitigation that remains: run the engine so the only files it can reach are ones the working-folder trust domain already covers, and say plainly in your README that chat text can read them. (A filesystem-resolving expander leaves one tell: a reference to a file that does not exist typically survives into the prompt unresolved.)
  • 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. Accepting 50 MiB is required; FORWARDING it is not: where your engine demonstrably cannot survive a request that large — one measured server’s process dies outright near 55 MiB and takes every in-flight turn with it — decline with 400 + context_too_long rather than passing it on and letting the crash answer for you.
  • 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 or 503 early when a request cannot start in time (the errors table’s overloaded row allows either). Decline early rather than late — a person told “busy” within a few seconds is better served than one left staring at a spinner for the full five minutes. The exact threshold is yours; a short spike is worth riding out.
  • Never log message content, images, or tokens.

Self-test (before pairing)

Recommended check. On the machine where the adapter runs, with TOKEN set, run this block — PORT is set once at the top, so a different port is a one-line change (8480 is only this document’s example):

PORT=8480
curl -fsSLO https://github.com/gigaduckai/conduck-connect/releases/latest/download/conduck-connect.sh
CI=1 CONDUCK_TOKEN="$TOKEN" bash conduck-connect.sh --check-adapter http://127.0.0.1:$PORT
CI=1 CONDUCK_TOKEN="$TOKEN" bash conduck-connect.sh --check-adapter --deep http://127.0.0.1:$PORT

CI=1 on the two checks makes a passing run print its summary and exit, instead of pausing to ask whether to continue into setup and pairing. Without it, a passing check run in a terminal a script or an AI coding tool OWNS prints PASS, looks finished, and then hangs on that question waiting for an answer that never comes; a run with no terminal at all exits on its own, and CI=1 simply makes that behavior unconditional. Drop it only when a person is driving the terminal.

Run BOTH profiles — the plain check and --deep. They are not the same check twice: --deep adds image handling and the deeper probes, and it routinely catches real faults the ordinary profile passes over. Treat a build as verified only when both exit 0 against the real engine, not the echo or scaffold you developed against.

--check-adapter grades an adapter built from this contract. Software that was NOT built for Conduck — OpenClaw, Hermes, Ollama, LiteLLM, any generic OpenAI-compatible server — is graded by --check-server instead; if you are following this page, --check-adapter is yours.

Compare the check’s revision= against this document’s. Every summary line names the contract revision that build of the checker grades. Compare the two dot-separated segments separately, never as a decimal — 1.10 is NEWER than 1.9, not older. When the checker’s revision comes before this page’s, the checker is BEHIND, not your build broken: the green you got proves conformance to the older revision, everything added since is graded by nothing, and no flag closes that gap — read the changelog below for what your build must honor on its own reading, record both numbers, and move on.

What an UNVERIFIED image result means. The deep profile’s image probe sends a picture carrying six digits and looks for those digits in the reply (one misread glyph is forgiven). Whether the reply reflects the picture is the only thing it can observe. A 200 that does not read them back is reported as UNVERIFIED and fails the profile, deliberately: from outside the adapter, a reply produced without the image and a reply from an engine that misread the image are the same answer, and the app cannot tell either one from a real sighting either. So an adapter that forwards the image correctly to an engine whose vision is weak lands on UNVERIFIED too. The probe cannot tell you which of the two you have — check the forwarding path first, and only once you have confirmed the image really does reach the engine is the result a statement about the ENGINE rather than about your adapter. There is no honest way to turn that run green from the adapter side: record the UNVERIFIED result and the engine that produced it, hand both to your operator, and stop there. Do NOT reach for image_unsupported on current-turn images you actually forward — that decline says your adapter cannot accept images at all, and making it true means rejecting every photo the user sends.

The check changes no host configuration. It does send live requests that may consume compute or enter server history; --files, when explicitly requested, also writes and deletes small probe files in the configured folder. It shows what passed and what to fix; green checks mean the tested requests and replies match what Conduck expects. In an interactive terminal, PASS then asks whether you want to continue with setup and pairing; say no if you only wanted the check. Non-interactive runs print the result and exit without waiting — and so does any run with CI=1 set, which is how a script or an AI coding tool driving its own terminal keeps a passing check from stopping at that question.

Check by hand. These three commands show the replies directly, but cover only the basics:

PORT=${PORT:-8480}
curl -s -H "Authorization: Bearer $TOKEN" http://127.0.0.1:$PORT/v1/models
# prints: {"object":"list","data":[{"id":"...","object":"model"}]}

curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:$PORT/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:$PORT/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.

Read the outbound half below as a statement about your ENGINE and its file server, not about your adapter code. The adapter’s entire outbound job is to let one line of message text reach the engine unchanged, which the request rules already require of it — so an adapter can be byte-perfect, pass every check, and still hand nothing back, because what decides the outcome is whether the engine will create a directory, where it writes, and what the file server can answer. If you enable the lane:

  • 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 that directory tree: inbound files arrive inside it, outbound files go in the per-dispatch folder named below, and nothing at all is read or written outside it.

  • The file server’s username is conduck — fixed, and load-bearing. The pairing payload carries only the lane’s URL and credential, no username field, so the app always authenticates as conduck. Configure the WebDAV server with any other username and every check can still be forced green (CONDUCK_FILES_USER exists only so the conformance check can grade an unconventional lane), while the paired app fails authentication with nothing on the adapter side to look at.

  • The file server and the ENGINE are usually two different users, and the lane needs both directions to work between them — the engine reading inbound uploads the server wrote, the server listing and deleting files the engine wrote. The recipe is ordinary UNIX: one shared group, a setgid working folder (2770 or 2775, group-owned), and a umask on both sides that keeps files group-writable — or simply run both as the same user. Without it the lane fails asymmetrically and late — typically listing and fetching work while DELETE quietly cannot.

  • For an engine invoked as a subprocess, run it with the working folder as its CURRENT WORKING DIRECTORY. Both directions speak in paths relative to the working folder, and the adapter is forbidden from rewriting message text — matching the engine’s cwd to the folder is what makes those relative paths simply true for it.

  • Inbound: attached files land in the working folder, under a per-conversation subfolder where the file server takes one, and the turn’s text names each file in quotes, followed by its exact path — - "report.pdf" (saved as <conversation-id>/<key>__report.pdf). The quoted string is only a display name — sanitized, and shortened when long. The path in parentheses, relative to the working folder, is where the file actually is, and it is the one to open. Treat it as opaque — the name inside it is sanitized separately, so it can differ from the quoted one, and a file server that refuses nested folders gets a flat name with no folder in front of it.

  • Outbound: Conduck names the destination and creates nothing. On a dispatch that may return files it mints a path for that one turn — a new subfolder inside the conversation’s own folder, with enough randomness in the name that nothing can already be sitting there — and appends exactly this line to the newest turn:

    [Conduck file transfer] Files you produce for this reply go in: <path>

    <path> is relative to the working folder. That folder does not exist when the agent reads the line — nothing creates it in advance, so creating it is the agent’s first step, every missing segment of it, the conversation folder above it included. Write the files you are handing back straight into it, and finish writing before you reply: once the turn is answered, Conduck lists that one folder and offers whatever is sitting in it. Naming the files in the reply is optional for that automatic delivery and changes nothing about it — an agent that says nothing about them at all is fully conformant. (Not quite nothing anywhere: a separate manual action in the app, which only a user tap starts, reads the reply’s prose for filenames and looks for them at the top of the working folder. Nothing about that is automatic, and no reply text ever causes Conduck to fetch anything on its own.) What the automatic lane never picks up is a file written anywhere else — the working folder’s top level, a scratch directory of the engine’s own, a subfolder of the named folder — because only files sitting directly inside the named folder are ever listed. The path is minted fresh for every dispatch, retries included, so a file left behind by an earlier attempt is never offered as this turn’s work. An attachment directive some agent platforms emit instead — a MEDIA: line and the like — is not a delivery route here: it is typically stripped by the platform’s own OpenAI-compatible endpoint before the reply ever leaves.

  • What has to be true for a file to come back. Three requirements. Miss any one of them and the lane hands back nothing, however correct the adapter is:

    • The AGENT MUST be able to create the folder, because Conduck will not. That direction is deliberate and it is measured: a directory created by the client belongs to whoever the WebDAV lane runs as — commonly root — while the agent runs as an ordinary user, so a client-created folder is one the agent can neither write into nor delete. Across six live self-hosted gateways, a folder the client created was unusable to the agent on two of them, and those two are the most common ones; a folder the client only named worked nearly everywhere. So give the agent a tool that can create a directory inside the working folder, and standing instructions that tell it to. That tool need not be the engine’s own: an engine with no file tools at all — a plain prompt runner — may be given one BY THE ADAPTER, as a structured action the model emits and the adapter executes inside the working folder; and an engine whose file tools live behind a distinct mode (an @agent route, a tools profile) may be dispatched into that mode for exactly the turns that carry the file-transfer directive — routing on the directive is a per-request decision, not server-side state. Both are fully conformant; what stays fixed is that the MODEL decides what gets written and the folder rules here hold. One engine-side trap can sink the lane with everything above correct: tools that advertise a permission-ESCALATION argument (a sandbox_permissions-style parameter). A weak model invokes it on every write, each call is refused as an escalation, and the turn loops to the deadline having produced nothing — indistinguishable from the folder failures above from outside. If the engine offers such an affordance, remove it or grant the folder outright, and test one real file-producing turn before trusting the lane.
    • The folder Conduck names is always nested, and the file server MUST be able to address it there. The path is two segments below the working folder — the conversation’s own folder, and the per-dispatch folder inside it — and there is no flat fallback for the outbound half. A file server whose namespace is effectively flat still takes inbound uploads, flat at the top of the working folder, but it has no way to answer for a path at this depth, so nothing ever comes back on it: the user’s own manual in-app search for files a reply mentions is then the only route to a download, and nothing the adapter or the engine does changes that.
    • PROPFIND MUST be answered — at Depth: 0 for the folder Conduck has just named, at Depth: 1 for that same folder once the reply is in, and, just as load-bearing, with a definite miss for a collection that is not there. A definite miss is either a 404 status line or the compliant 207 whose one inner response names that exact collection with a 404/410 of its own; both count. Listing is the only way Conduck learns what the agent produced, and there is no fallback to anything else; the definite miss is what a sending device uses to establish the folder is fresh before the turn goes out, and what proves on every listing that this lane is capable of saying no. A server that answers 207 for every path it is asked about is refused as firmly as one that does not answer PROPFIND at all, because its “yes” carries no information. MKCOL is not among the requirements for this half. Conduck names the outbound folder and neither creates it nor writes into it, so PROPFIND is the only method the client ever aims at that collection — the files found inside it are then fetched with an ordinary GET, sometimes ranged — and what the file server answers to MKCOL decides nothing about whether files come back. (MKCOL does matter in the other direction, where it decides whether an inbound upload lands in a per-conversation subfolder or flat — see the Inbound bullet above. That direction degrades; it does not fail.) The PROPFIND requirement genuinely rules out some ordinary file servers: Caddy’s stock file_server answers no WebDAV method at all, and nginx’s ngx_http_dav_module handles PUT, DELETE, MKCOL, COPY and MOVE and nothing else — so it can take an upload and can never answer a listing. rclone serve webdav, which is what conduck-connect installs when it sets the lane up, answers all of it. Whatever serves the lane, one behavior is required of it: a file just written to the folder on disk MUST appear in the next PROPFIND listing within a couple of seconds, because Conduck lists the folder as soon as the reply arrives. For rclone that means installing it with --dir-cache-time 1s (or lower), the way conduck-connect does: rclone’s directory-listing cache defaults to five MINUTES, so on stock defaults a just-written output file stays invisible to PROPFIND — and is therefore never delivered — until the cache happens to expire. A build that sets up its own rclone from rclone’s own docs hits this as a lane that silently hands back nothing.
  • Common document/data/code extensions are recognized (pdf, csv, json, md, zip, png, xlsx, py, …); a file whose extension is not among them stays where it is instead of being offered. The number of files taken from one folder is bounded, so an agent that writes hundreds into it should not expect every one of them to appear.

Then connect it

After an interactive --check-adapter PASS, the operator can answer yes when the script asks whether to continue with setup and pairing. It reuses the URL and token just checked, helps expose the adapter over HTTPS, verifies the final reachable address, and prints the setup code as a QR code. Exposure and pairing are intentional human decisions: an AI builder must answer no, stop after verification, and hand this step to its operator.

The setup code itself is not wizard-only. Its payload is a stable, versioned format — PAYLOAD.md in the conduck-connect repo specifies conduck-setup:v1 completely, URL admissibility rules included — and conduck-connect.sh --emit-code (0.16.0 and later) mints a code non-interactively from a URL, CONDUCK_TOKEN, and optionally a file lane, on a machine that has never been through setup. (A build older than 0.16.0 has no such flag — check --version; there, the operator’s interactive wizard remains the only minter.) (--show-code is different: it only re-displays what a completed --setup saved, so on a fresh build rig it has nothing to show.) Minting a code is not pairing — SCANNING it into a device is, and that decision stays with the operator exactly as above.

If the check ran non-interactively, or you answered no, start setup directly on the adapter’s machine:

curl -fsSLO https://github.com/gigaduckai/conduck-connect/releases/latest/download/conduck-connect.sh && bash conduck-connect.sh --setup

Setup reports any OpenClaw or Hermes installation it finds, then asks what you want to configure. Detection never selects a gateway on your behalf, so you can always choose this adapter or another OpenAI-compatible server. 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 whose certificate the device already trusts (a self-signed one is refused outright, and no app setting can override that); 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 the setup code as a QR code to scan in Conduck.

The wizard needs the operator at a real interactive terminal: prompts cannot be piped, and there are no non-interactive answer flags. An AI builder must stop before this step and hand the final exposure and pairing decisions to the operator.

Versioning

This is version 1 of the contract — document revision 1.10. This URL is stable. Any change that would make a conforming adapter nonconforming ships as /setup/adapter/v2 — v1 stays; pure clarifications may continue to land here with a visible changelog entry below.

  • Revision 1.10 (2026-08-27): clarifications plus one additive error code; no existing wire rule changes meaning. The 1.10 checker does add one counted grade — CHAT_FRAMING reads whether the pong turn’s ANSWER survived your scaffold, grading what revision 1.9’s framing rule already said in words — so a build that was green on the 1.9 checker can turn red without any rule having moved. Informed by eight more independent from-scratch builds (all-new engine harnesses — SDK frameworks, stateful platforms, tool-less prompt runners, and coding CLIs among them), each independently re-verified.
    • The pairing payload is now named as reachable. All eight builders concluded, correctly from this page and the brief alone, that a conduck-setup:v1 code could not be produced without the operator’s interactive wizard — while the payload spec (PAYLOAD.md) has been public all along with nothing pointing at it. “Then connect it” now links the spec and names --emit-code (conduck-connect 0.16.0+) as the non-interactive minter, with the boundary restated: minting is a build step, scanning is pairing and stays with the operator.
    • The file lane’s username rule is written down: the WebDAV user must be conduck — the pairing payload carries no username field, so the app always authenticates as that. Also new in the lane: the two-user permission recipe (shared group, setgid folder, group-writable umask), the subprocess-cwd rule (paths in both directions are relative to the working folder), the adapter-supplied and mode-gated file-tool patterns stated as conformant, and the permission-escalation-argument trap that loops a weak model to the deadline.
    • The response section now covers turns that answer by doing: an engine that finished the work but produced no words is still a 502, never an adapter-authored answer; engines that render tool calls into message text need the machinery stripped and the last message WITH an answer returned; and a framework “success” whose text is an error message is a 502, not a confident 200. All three shipped as real defects in otherwise-green builds.
    • Images: an image_url that is not an inline data: URI is malformed — 400, never fetched. The earlier-image disclosure is stated to be legitimate for any adapter whose framework binds images to the current turn, vision or not. An engine that refuses empty prompt text may be given a fixed neutral carrier sentence for image-only messages, now specified verbatim — the one licensed exception to part-text preservation, scoped to the empty text part and nothing else.
    • Errors: body_too_large (413) joins the vocabulary for a body refused straight from Content-Length, where claiming image_too_large would assert something the adapter cannot know. Additive: clients treat unknown codes as absent, so an adapter already sending the bare 413 is exactly as conformant as before. Chunked bodies are resolved rather than deferred: Conduck’s chat POSTs always carry Content-Length, and refusing a body-bearing request without one is conforming — 411 joins the status table for exactly that case, and GET /v1/models, which has no body, is never refused on this basis.
    • Connection reuse: the simplest conforming policy — Connection: close on every response — is stated outright, and the claim that nothing could observe reuse locally is corrected: --check-adapter probes the 401 path (a green there means no failure observed, not every early-rejection path proven). Python’s http.server gets its three-line recipe instead of a warning with no exit.
    • Smaller clarifications: /v1/models stays answerable while a chat turn runs; “return 429/503 early” means within a few seconds; an agent step cap reports finish_reason: "length" when the cap is what ended the answer; parse strictly on the way in (some parsers accept NaN); accepting the 50 MiB floor does not oblige forwarding it to an engine that dies on it (400 + context_too_long); the @path rule gains its third branch (neither disableable nor invisibly defeatable → confine and disclose); both self-test blocks take their port from a PORT variable; the Self-test section names --check-server for software not built from this page, and explains how to compare a checker’s revision= against this document’s (segment by segment, never as a decimal); the CONDUCK_CHECK_ADAPTER summary line is now schema=4 — tooling that parses it should read the schema field.
  • Revision 1.9 — revised in place (2026-08-26): clarifications only; no wire rule changed, and every adapter that passes the current checks passes them still. Informed by eight independent from-scratch builds across four agent frameworks.
    • The self-test command block now sets CI=1 on both checks, matching the build brief. Without it a check driven in a terminal a script or an AI coding tool owns prints PASS and then hangs on the continue-into-pairing question — the one hazard the brief calls out most loudly. (Revision 1.4 added the surrounding prose mention; the runnable block itself never carried the variable until now.)
    • Images gains a note for engines with no inline image channel of their own — terminal coding agents especially. Forwarding a current-turn image by writing it to a per-turn file the engine reads and naming that path is conformant (you need not decline); “attached” is not “seen” — some frameworks convert an attached image into a text description, which --deep catches as UNVERIFIED. One reading of the part-order rule did bind an image’s interleaved position among the text; this entry resolves that the way revision 1.5 resolved its image rule — in the direction the published checker has always graded, which has passed file-channel forwarding since the probe existed. The order rule binds the text parts; every current-turn image must still reach the engine, and only its position may be lost.
    • The file-lane section now states the freshness requirement behaviorally — a file just written to disk must appear in the next PROPFIND listing within a couple of seconds — and names the --dir-cache-time 1s flag on rclone serve webdav as the recipe. rclone’s directory cache defaults to five minutes, so a build that stands up its own rclone from defaults finds that a just-written output file stays invisible to PROPFIND, and the lane silently delivers nothing, until the cache expires. conduck-connect already installs it with this flag; the requirement was simply never written down.
    • The security floor now says a busy adapter returns 429 or 503 (the errors table always allowed both), and adds that an engine which expands @path-style file references inside the prompt text is a file-read path structured argv does not close.
    • Two smaller clarifications: a suggested stable wording for the unknown-attachment note, and that an unauthenticated request to an unknown path is a 401, because auth is checked before routing.
  • Revision 1.8 (2026-08-23): purely additive — one new section, “Optional response metadata”, and nothing else moves. No request or response shape changes, no status or code changes, and every adapter that passes the current checks passes them still. The client may now observe top-level model and id, usage, and choices[0].finish_reason where a reply happens to carry them, and it reads them for one purpose: a private, on-device account of how the person’s own gateway is behaving. Two recommendations come with that and they are SHOULDs, not requirements — report usage for the whole turn rather than one call inside it, and report finish_reason: "length" honestly when the engine hit an output limit. Every one of the fields stays optional permanently, a malformed one is ignored by itself, and an adapter that reports none of them is as conformant as it was before this revision. Nothing about pairing changes and no gateway needs re-pairing. The one existing line that had to move is the response section’s “everything else is ignored”, which was true when it was written and would now be a promise the client no longer keeps.
  • Revision 1.7 — revised in place (2026-08-18): no wire rule changed, so the document revision stays 1.7 and every conforming adapter stays conforming, and nothing an already-working installation does changes. This page told gateway authors that the outbound file lane required MKCOL of the file server, and that a lane failing it was never given a folder at all. The app does not work that way: Conduck NAMES the outbound folder and creates nothing, so PROPFIND is the only method the client ever aims at that collection, and what the file server answers to MKCOL decides nothing about whether files come back. The requirement is removed rather than re-worded, because it excluded file servers that work, and the strictly wider truth is stated in its place — the two PROPFIND depths the app actually asks for, what counts as a definite miss, and the depth the outbound path is always named at. MKCOL keeps the job it really has, in the INBOUND direction, where it decides whether an uploaded attachment lands in a per-conversation subfolder or flat at the top of the working folder; that direction degrades rather than fails, and the Inbound bullet has always said so.
  • Revision 1.7 (2026-08-12): the optional file lane’s outbound half is rewritten end to end. Nothing in the two chat routes moves — no request or response shape changes, no status or code changes, and an adapter that passes the current checks passes them still.
    • The versioning promise below cannot express this change, so it is stated here in words. That promise is about adapters: a change that would make a conforming adapter nonconforming ships as /v2. This change makes no adapter nonconforming, and it can still stop a working installation from delivering a single file — because the file lane is now a profile about the AGENT and its file server rather than about adapter code. Your adapter can be byte-perfect, pass every check green, and the lane still hand back nothing. What has to change is the engine’s standing instructions — its system prompt, its AGENTS.md-style project file, whatever your framework calls the place its habits are written down. Two habits in particular now deliver nothing the user can tap: writing output to the working folder’s root, and writing into a folder without creating it first, since nothing creates the destination in advance. No adapter change fixes either. Edit the engine’s instructions, then check the file server against the requirements below.
    • Conduck names the destination and creates nothing; the reply says nothing. Every dispatch gets a freshly NAMED subfolder inside the conversation’s folder — named by Conduck on one line of the newest turn, [Conduck file transfer] Files you produce for this reply go in: <path>, and created by the AGENT, which is the whole point — and the app lists exactly that folder once the reply is in. One obligation is new: creating that folder, every missing segment of it. Four are gone: writing to the working folder’s ROOT, stating the filename verbatim on a line of its own, the rule that a file reached the user only when its filename appeared in plain reply text, and the description of the app spotting that filename. Naming files in the reply is now optional and changes nothing about automatic delivery; the one thing it still feeds is a manual, user-tapped in-app search, which reads reply prose but never runs on its own. The MEDIA: clause is unchanged: a platform’s own attachment directive is still not a delivery route, because the platform’s OpenAI-compatible endpoint typically strips it before the reply leaves.
    • Two genuine narrowings, neither of them on the adapter. PROPFIND MUST now be answered — Depth: 1 for the per-dispatch folder, and a definite 404 for a collection that is not there. Listing is the only way the app learns what was produced, and the definite miss is what proves the folder fresh before the turn and proves the lane able to say no; a catch-all that answers 207 for everything is refused like a server with no PROPFIND at all. That excludes servers previously fine here — Caddy’s stock file_server, and nginx’s ngx_http_dav_module, whose method set stops at PUT/DELETE/MKCOL/COPY/MOVE (rclone serve webdav, which conduck-connect installs, answers all of it). And the folder must be one the AGENT can create, because nothing else creates it — an engine that cannot make a directory inside the working folder has no delivery route, and the user’s manual in-app search is its only remaining one. Neither was required before, and neither is something an adapter can compensate for.
    • A correction, not a behaviour change: the old “at most 5 files per reply are checked” was already wrong when it was written, against the app’s own cap at the time. It is removed rather than re-numbered — a figure written into this document drifts from the constant that owns it, which is exactly what happened — and replaced with the honest shape of the rule: the count taken from one folder is bounded, and an unrecognized extension is left alone. The recognized extension list itself is unchanged.
    • The per-dispatch folder is a storage location, not an identifier. The conversation-snapshot section already forbids keying server-side state on file paths; that now says so about the outputs folder too, which is minted fresh every dispatch. The same parenthetical claimed the <conversation-id> folder name never appears in a chat request — it does, as ordinary text inside the inbound file list, and the line now says so.
  • Revision 1.6 (2026-08-11): five clarifications; no wire rule changed. No request or response shape changed, both routes are untouched, and every adapter the current checks pass keeps passing them. The last four are places where the prose said less than the app and its conformance check actually do.
    • The stream rule is restated where builders actually read it. The rule itself is unchanged. Five adapters were built independently against revisions 1.3 and 1.4, by five different tools on five live servers, and all five answered stream: true with SSE. The rule was never the disagreement: the conformance check sends Accept: application/json and got SSE regardless, so all five branched on the request flag alone — and none of them had been graded green before it shipped. What they had in common was the document they were actually built FROM. The build brief, which wraps this contract in a step-by-step workflow and is what an AI coding tool follows, did not mention the flag once; it now states the rule at the point where the response is written. This page’s own opening promised “the OpenAI chat-completions HTTP format” with no qualification, which primes the streaming reflex that the request-shape bullet then has to fight forty lines later; it now says which part of that format Conduck speaks, and points at the rule. And the check that grades the rule now sends an Accept header that ASKS for a stream, because a probe that only ever asks for JSON structurally cannot see an adapter that decides by content negotiation — the one shape of this defect the five builds happened not to have.
    • Consecutive same-role messages are stated outright. Two user messages in a row is a real shape — Conduck keeps a turn whose reply never arrived and appends the next one after it — so the adapter MUST accept it, and an engine that requires strict alternation must be bridged adapter-side, inside the array-to-engine mapping this document already leaves open. The text had only ever promised that the FINAL message is a user turn, while --check-adapter has sent an image-bearing user message followed by a text-only one all along.
    • error.message and error.type are now MUST, both non-empty. The requirement itself is not new — “a JSON body in the OpenAI error shape” always meant both fields, and the check rejects a graded decline (image_unsupported, model_not_found) that omits either — but neither field carried a keyword, and this document’s own opening says that only the keywords bind, so the shape was binding on the two declines the check grades and merely suggested everywhere else. The same line now says what message is really for: never shown verbatim, yet read by the app on some statuses to work out a failure that carries no recognized code.
    • The file lane’s inbound example was wrong. The app puts the filename in quotes and follows it with the saved path, - "report.pdf" (saved as …) — the quoted string is a display name, the path is the thing to open, and it has no folder prefix on a file server that cannot take nested folders. The outbound bullet now carries the instruction the app actually appends to the turn — name the file in plain text, on a line of its own — and notes that a platform’s own attachment directive (MEDIA: and the like) is not a delivery route.
    • An UNVERIFIED image result is explained. The deep profile’s probe can only observe whether the reply reads the picture’s digits back, so a correct adapter in front of an engine with weak vision lands there too. Confirm the forwarding path, then treat it as a statement about the engine rather than about the adapter — the run stays red, and the answer to it is never a false image_unsupported decline on images you do forward.
  • Revision 1.5 (2026-08-10): one image rule is tightened to end a contradiction between this document and its own conformance checker; no other rule, shape, or route changes. A CURRENT-turn image is now forwarded to the effective engine or the request is REJECTED — with 400 + code image_unsupported when the engine cannot accept images, or the errors table’s existing 413 + image_too_large when one exceeds a cap. What no longer conforms is answering 200 without the engine having seen it. Earlier text paired “MUST NOT silently discard” with the unknown-part replace-with-a-note option, which could be read as permitting a 200 that substitutes a note for the newest message’s image — while the --check-adapter --deep image probe has failed exactly that reply since revision 1.3 (a 200 without the probe’s digits is UNVERIFIED, note or no note). The prose now says what the probe has always enforced, in the probe’s favor: a substituted note is not a sighting, and the app presents such a reply as a confident answer about the photo. Adapters that pass --check-adapter --deep are unaffected; an adapter that relied on the note reading should adopt the text-only recipe above — decline the current-turn image with image_unsupported, and disclose earlier-message images in position. Also clarified: the unknown-part replace-in-position option covers unknown part types only, and the capability-signaling line no longer names a specific revision. This knowingly narrows the “clarifications only” rule: two readings could not both stand against one checker, and versioning the document around the reading the published checker never accepted would have made /v2 out of a sentence.
  • Revision 1.4 — revised in place (2026-08-10): no wire rule changed, so the document revision stays 1.4 and every conforming adapter stays conforming. Self-test now names CI=1. A check that PASSES in a terminal a script or an AI coding tool owns otherwise goes on to ask whether to continue into setup and pairing — after it has already printed its result — and waits there; CI=1 makes it print and exit, which is what a non-interactive run already did. Terminology follows the app: what the wizard prints at the end of setup is the setup code, displayed as a QR code.
  • Revision 1.4 (2026-07-29): informed by five independent from-scratch adapter builds against revision 1.3 on live servers. No request or response shape changed and both routes are untouched. The one new normative line — connection reuse — restates an obligation HTTP/1.1 already imposed, so an adapter that was correct on the wire stays conforming. Connection reuse is now stated outright: a rejection sent before the request body has been consumed must not be followed by another request on that connection unless the remainder is drained — otherwise close it. That restates HTTP/1.1’s own rule rather than adding a wire requirement, but three of the five builds got it wrong, and loopback curl structurally cannot catch it. Model selection is split into a two-or-more rule and a one-model rule, so the single-model exemption can no longer be read as sitting beside the model_not_found requirement; a new line says the id you advertise is a selector that may differ from your engine’s own name for that model and must be mapped onto it. The persistent-session exception now names the one comparison that establishes a prefix — structural equality of the complete prior transcript against the leading messages — and says plainly that a record of what you sent cannot speak for an engine that compacts or injects context of its own. Also stated: v1 carries no conversation identifier, so there is nothing to key session state on. The Conversation-snapshot rules are restated in RFC keywords: they were written as plain imperatives, which this document’s own opening says do not bind. Timing gains the control it was missing — the body read sits outside the 285-second deadline by design, so the transfer itself needs its own read/idle timeout; what a stalled upload is answered stays unspecified. The Self-test section now runs both profiles in its command block, because --deep catches faults the ordinary profile passes over. Clarified: an engine that only streams is fine, buffer it; the text-only recipe signals input_modalities: ["text"] explicitly, since omitting the field means unknown; mapping the message ARRAY into an engine prompt is deliberately the adapter’s own decision; and content is read aloud on some surfaces, so an engine left in a patch-format or tool-log output mode yields a conformant but unusable reply. Corrected an over-promise in “What you are building”: an agent keeps its code, home and tools, but running headless routinely means changing its CONFIGURATION — approval policy and output mode — and the page said it stayed exactly as it was.
  • Revision 1.3 — revised in place (2026-07-28): no wire rule changed, so the document revision stays 1.3 and every conforming adapter stays conforming. Corrected the exposure line under “Division of labor”: conduck-connect never pins a self-signed certificate to make it trusted — the certificate must already be one the device trusts before conduck-connect will expose the adapter behind it. The same constraint is now repeated in “Then connect it”, where a reader who skipped the division-of-labor line meets the exposure routes for the first time.
  • Revision 1.3 — revised in place (2026-07-25): no wire rule changed, so the document revision stays 1.3 and every conforming adapter stays conforming. Clarified the safety boundary around the interactive PASS handoff: the operator may continue into HTTPS exposure and pairing, while an autonomous AI builder must stop and hand that decision to the operator.
  • Revision 1.3 — revised in place (2026-07-24): no wire rule changed, so the document revision stays 1.3 and every conforming adapter stays conforming. conduck-connect’s commands were renamed: --doctor is now --check-adapter (--deep still selects the deeper profile), and --generic is replaced by the public --setup command, which reports any gateway it detects but never selects one on your behalf — so the earlier “skips auto-detection” caveat no longer applies. A passing interactive check now offers to continue straight into setup and pairing, reusing the URL and token you just checked; the Self-test and “Then connect it” sections describe that handoff. Existing App Store clients still use an unadvertised --generic compatibility alias; it remains outside the public command vocabulary. Client wording clarified: Apple is the compatibility authority and the Android client is a work in progress, not yet one. The checks’ machine-readable summary lines were bumped — CONDUCK_CHECK_ADAPTER to schema=3 and CONDUCK_CHECK_SERVER to schema=2 — so any tooling that parses them must read the schema field.
  • 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.
Back to setup