Skip to main content

Relay Server Protocol

This page is the contract between the host application that runs the Editor AI SDK tool loop and a relay server. Read the request side when you write your own createMessage(), and the response and error side when you write your own relay. The Playground and the Thinkfree Relay Server follow this contract, and the relay server guide shows a Node.js skeleton that implements the server side.

Endpoints

Method and pathPurposeSuccess response
POST /ai-agent/relayForward one model request to the providerThe provider response, unchanged: JSON, or SSE when stream is true
OPTIONS /ai-agent/relayCORS preflight204 with the allow headers
GET /healthLiveness check{"ok":true,"relayPath":"/ai-agent/relay"}
POST /ai-agent/relay/files/uploadUpload an attachment to the provider's file store1{"uri":"…","name":"…"}
POST /ai-agent/relay/files/deleteDelete a file from the provider's file storeThe provider response, or {"success":true}

1. Available only for providers that offer a file store. See File helpers for the supported scope.

The relay path is a deployment choice. /ai-agent/relay is the path the Thinkfree Relay Server uses, and the Playground reads it from its configuration rather than hard-coding it. Any other path inside the relay namespace returns the error body with route-not-found (404); any method other than POST on a relay path returns relay-method-unsupported (405).

Request body

POST /ai-agent/relay with Content-Type: application/json. The body is one JSON object with two reserved keys. Any other top-level key, for example a tenant identifier that your relay checks, is allowed and must not reuse the two reserved names.

{
"tfAgentRelay": { "…": "what this turn means" },
"tfAgentTransport": { "…": "which HTTP request to execute" }
}

A complete request for Claude without streaming, as the Playground would send it for a one-tool loop:

{
"tfAgentRelay": {
"requestId": "8c1d0f0e-2f7a-4d63-9b1e-3a5f6c7d8e90",
"turnId": "8c1d0f0e-2f7a-4d63-9b1e-3a5f6c7d8e90",
"runtimeId": "runtime-1",
"agentId": "office-agent",
"sessionId": "b1a2c3d4-0000-4000-8000-000000000001",
"aiProviderConfig": { "connectionMode": "relay", "aiProvider": "claude", "model": "claude-sonnet-5", "maxToolIterations": 8 },
"systemPrompt": "You edit the open Word document with the provided tools.",
"transcript": [
{ "role": "user", "content": "Append a short greeting.", "messageContent": { "parts": [{ "type": "text", "text": "Append a short greeting." }] } }
],
"input": { "parts": [{ "type": "text", "text": "Append a short greeting." }] },
"tools": [
{ "name": "insert_text", "description": "Append text to the document.", "inputSchema": { "type": "object", "properties": { "text": { "type": "string" } }, "required": ["text"] } }
],
"context": {
"runtime": null,
"agent": null,
"session": { "sessionId": "b1a2c3d4-0000-4000-8000-000000000001", "sessionName": null, "sessionDisplayName": null, "properties": {}, "aiProviderConfig": { "connectionMode": "relay", "aiProvider": "claude", "model": "claude-sonnet-5" } },
"turn": { "requestId": "8c1d0f0e-2f7a-4d63-9b1e-3a5f6c7d8e90", "turnId": "8c1d0f0e-2f7a-4d63-9b1e-3a5f6c7d8e90", "turnName": null, "turnDisplayName": null, "properties": { "documentId": "doc-42" } }
}
},
"tfAgentTransport": {
"adapterId": "claude-messages",
"aiProvider": "claude",
"model": "claude-sonnet-5",
"stream": false,
"body": {
"max_tokens": 8192,
"system": "You edit the open Word document with the provided tools.",
"messages": [{ "role": "user", "content": "Append a short greeting." }],
"tools": [{ "name": "insert_text", "description": "Append text to the document.", "input_schema": { "type": "object", "properties": { "text": { "type": "string" } }, "required": ["text"] } }]
}
}
}

tfAgentRelay: what this turn means

tfAgentRelay is the host application's snapshot of the turn in provider-neutral form. A relay uses it for logging and for policy that should not depend on provider payload formats, and it checks a small part of it against tfAgentTransport. The Checked column says what a relay verifies; everything else is carried for the relay's own use.

FieldTypeCheckedMeaning
requestIdstringEchoed in the error body and logsIdentifies one model request end to end
turnIdstringEchoed in the error body and logsIdentifies the user turn; the same across the requests of one tool loop
runtimeIdstring or nullNoIdentifier of the runtime instance, when the application has one
agentIdstringNoIdentifier of the agent configuration that owns the conversation
sessionIdstringNoIdentifier of the conversation; one document, tab, or task
aiProviderConfigobjectaiProvider and model must equal the values in tfAgentTransportThe provider settings the host application believes it is using
relayVendorstring or nullMust equal tfAgentTransport.vendor when both are presentVendor selected by the application when the relay serves more than one. A vendor is the name of a provider route configured on the relay
systemPromptstringNoThe system prompt in effect for this turn
transcriptarray of transcript messagesNoThe conversation before this turn
inputmessage contentNoThe current user input
toolsarray of tool descriptorsNoThe tool catalog offered to the model
contextobjectShape only: each scope is null or an object, and properties is an object when presentSnapshots of the runtime, agent, session, and turn

aiProviderConfig carries connectionMode ("relay"), aiProvider, model, and optionally openAiCompatible (preset, used by OpenAI-compatible routes), maxToolIterations, and auth. In relay mode auth is absent or null; the host application has no provider credential to send.

context has four scopes: runtime, agent, session, and turn. Each is null or an object with the scope's identifiers, an optional display name, and a properties object that your application fills with its own values, for example a document or tenant identifier. Values placed there are the natural way to hand authorization data to your relay, because a relay validates the shape of properties and never forwards it to the provider.

The three content shapes inside tfAgentRelay:

ShapeFields
Transcript messagerole ("user", "assistant", or "tool"), content (string), optional messageContent, requestId, turnId, meta, timestamp
Message contentparts: an array of { "type": "text", "text" }, { "type": "image", "source", "mediaType", "fileName" }, or { "type": "file", … }
Tool descriptorname, description, inputSchema (JSON Schema), optional annotations such as readOnlyHint and destructiveHint

tfAgentTransport: which HTTP request to execute

tfAgentTransport is what the relay executes. Its body is the request the host application sends to the provider API in direct mode, so the relay does not translate anything; it adds the credential and the final model and forwards.

FieldTypeRequiredMeaning
adapterIdstringYesThe provider adapter that produced body; see the table below
aiProviderstringYesProvider family: claude, google, openai, or openai-compatible
modelstringSend it alwaysThe requested model. A relay may replace it with the model the operator pinned, and some deployments reject a request without it
vendorstringOnly when the relay serves several vendors for one aiProviderName of the provider route (vendor) configured on the relay. It tells apart several routes for the same aiProvider that differ in credential or allowed models. Omit the key instead of sending null
streambooleanYestrue asks for the provider's SSE stream; false asks for one JSON body
pathHintstringNoPath appended to the configured endpoint; OpenAI-compatible routes only
queryobject of stringsNoQuery parameters appended to the provider URL; OpenAI-compatible routes only
safeHeadersobject of stringsNoHeaders the host application considers safe to forward. Relay-owned headers such as the credential always win
bodyobjectYesThe provider-ready request body

adapterId names the provider adapter the host application used to build body, and together with aiProvider it determines the provider endpoint the relay calls. The combinations in use are:

adapterIdaiProviderProvider endpoint the relay calls
claude-messagesclaudeMessages API
google-gemini-3, google-gemini-2googlegenerateContent, or streamGenerateContent?alt=sse when stream is true
openai-chat-completionsopenaiChat Completions
openai-compatible-chat-completionsopenai-compatibleThe configured endpoint plus pathHint, chat/completions by default

What body looks like per provider, reduced to the fields a tool loop needs:

{ "max_tokens": 8192, "system": "…", "messages": [{ "role": "user", "content": "…" }], "tools": [{ "name": "insert_text", "description": "…", "input_schema": { "type": "object" } }] }
{ "systemInstruction": { "parts": [{ "text": "…" }] }, "contents": [{ "role": "user", "parts": [{ "text": "…" }] }], "tools": [{ "functionDeclarations": [{ "name": "insert_text", "description": "…", "parameters": { "type": "object" } }] }] }
{ "messages": [{ "role": "system", "content": "…" }, { "role": "user", "content": "…" }], "tools": [{ "type": "function", "function": { "name": "insert_text", "description": "…", "parameters": { "type": "object" } } }], "stream": false }

The relay adds model to the body for Claude, OpenAI, and OpenAI-compatible routes and puts it in the URL for Gemini. It adds the credential as x-api-key (Claude), Authorization: Bearer (OpenAI and OpenAI-compatible by default), or the key query parameter (Gemini). It never adds conversation state: the body already contains the whole conversation.

Validation and policy

A relay checks the request in this order and stops at the first failure. Nothing is sent to the provider until every check passes.

OrderCheckError codeStatus
1Request Origin is allowedcors-origin-not-allowed403
2Body is present, within the size limit, and valid JSONrelay-request-empty
relay-request-too-large
relay-request-json-invalid
400
413
400
3Body, tfAgentRelay, tfAgentTransport, and tfAgentTransport.body are JSON objects; context scopes have the right shaperelay-request-invalid400
4adapterId, aiProvider, and stream are present with the right types; model and vendor are strings when presentrelay-transport-adapter-missing
relay-transport-provider-missing
relay-transport-model-missing
relay-transport-vendor-invalid
relay-transport-stream-invalid
400
5tfAgentRelay.aiProviderConfig and tfAgentTransport name the same provider, model, and vendorrelay-provider-mismatch
relay-model-mismatch
relay-vendor-mismatch
400
6The vendor route exists and matches aiProviderrelay-vendor-not-allowed
relay-provider-not-allowed
relay-vendor-required
relay-vendor-provider-mismatch
relay-provider-unsupported
403
403
400
400
400
7The model is in the route's allowlistrelay-model-not-allowed403
8The route has a credential and a usable configurationprovider-config-missing
provider-config-invalid
relay-config-unavailable
500
500
503

Policy resolution follows from checks 6 and 7. The route is the vendor named in tfAgentTransport.vendor, or in tfAgentRelay.relayVendor, or, when neither is present, the only configured route whose aiProvider matches. The model is the requested one when the route allows it; when the request carries no model, the route's default applies. A deployment may go further and pin the model on the server: the request still has to name a model, and the response comes from the pinned one.

Response

Success

The relay answers 200 and passes the provider response through unchanged, so what the host application reads is the provider's own format. Content-Type is the provider's for JSON. For a stream it is text/event-stream; charset=utf-8 with Cache-Control: no-cache, no-transform, and each SSE event arrives as the provider emitted it.

ProviderWhere the tool loop reads
Claudecontent[] blocks with type text or tool_use; stop_reason is tool_use while the model wants tools
Geminicandidates[0].content.parts[] with text or functionCall; candidates[0].finishReason
OpenAI and OpenAI-compatiblechoices[0].message.content and choices[0].message.tool_calls[]; choices[0].finish_reason

Because nothing is translated, a host application that switches from direct mode to relay mode keeps its response handling; only the request URL and the envelope change.

Error body

When the relay itself refuses or fails a request, it answers with its own JSON body. Provider error bodies are not forwarded; the relay summarizes them and reports the provider's status in relay.upstreamStatus.

{
"error": {
"code": "relay-model-not-allowed",
"message": "Relay model \"claude-opus-5\" is not allowed.",
"details": { "model": "claude-opus-5" }
},
"relay": {
"requestId": "8c1d0f0e-2f7a-4d63-9b1e-3a5f6c7d8e90",
"turnId": "8c1d0f0e-2f7a-4d63-9b1e-3a5f6c7d8e90",
"upstreamStatus": null
}
}
FieldMeaning
error.codeStable, machine-readable code from the tables on this page
error.messageHuman-readable explanation. Show it to a developer, not to an end user
error.detailsnull, or an object with the offending value, for example { "model" }, { "path" }, { "method" }, or { "upstreamStatus" }
relay.requestId, relay.turnIdCopied from tfAgentRelay when the body could be read; otherwise null
relay.upstreamStatusThe provider's HTTP status when the failure came from the provider; otherwise null

Codes the host application should expect after a request has passed validation:

CodeStatusWhen
upstream-request-failed502The provider answered with a non-success status. Read relay.upstreamStatus: 401 is the key, 429 is the provider's rate limit
relay-config-unavailable503The relay is running but has no usable provider configuration
relay-internal-error500An unexpected failure inside the relay
route-not-found404Unknown path inside the relay namespace; details.path is the requested path
relay-method-unsupported405A method other than POST on a relay path; details.method is the method

A streaming response that fails after the first chunk cannot be turned into this body, because the status line has already been sent. The relay closes the stream; the host application should treat an incomplete stream as a failed request and check the document before retrying.

CORS

The relay allows either exactly one origin or *. With an exact origin, a request whose Origin header differs is refused with cors-origin-not-allowed before routing, including the preflight, and the response carries Access-Control-Allow-Credentials: true. With *, every origin is allowed and the credentials header is not sent. Requests without an Origin header, such as server-to-server calls and curl, are not subject to CORS and are processed. The preflight response is 204 with Access-Control-Allow-Methods: POST, GET, OPTIONS and the requested headers echoed in Access-Control-Allow-Headers.

File helpers

Attachments for providers with a file store are uploaded through the relay so that the provider key stays on the server. Both helpers accept an optional tfAgentRelay object, whose identifiers are echoed in errors and logs, and answer with the same error body as the main endpoint.

EndpointRequest fieldsSuccess response
POST /ai-agent/relay/files/uploadmimeType, base64Body{ "uri", "name" } from the Gemini Files API
POST /ai-agent/relay/files/deleteprovider ("google" or "openai"), then fileUri for Google or fileId for OpenAIThe provider's delete response, or { "success": true }

Capability discovery

A relay may publish which vendors and models it allows, so that a settings UI can offer only those choices. The reference implementation serves it at GET /ai-agent/relay/capabilities; the Thinkfree Relay Server does not, and answers route-not-found. Treat it as optional and do not depend on it for the request itself.

{
"protocolVersion": "1",
"relayPath": "/ai-agent/relay",
"capabilitiesPath": "/ai-agent/relay/capabilities",
"defaultVendor": "google",
"vendors": [
{ "vendor": "google", "displayName": "Google Gemini", "aiProvider": "google", "models": ["gemini-3.8-flash"], "defaultModel": "gemini-3.8-flash" }
]
}

The response lists vendors, display names, the aiProvider each vendor maps to, the allowed models, and the default. It never contains keys, provider endpoints, or header and query overrides.