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 path | Purpose | Success response |
|---|---|---|
POST /ai-agent/relay | Forward one model request to the provider | The provider response, unchanged: JSON, or SSE when stream is true |
OPTIONS /ai-agent/relay | CORS preflight | 204 with the allow headers |
GET /health | Liveness check | {"ok":true,"relayPath":"/ai-agent/relay"} |
POST /ai-agent/relay/files/upload | Upload an attachment to the provider's file store1 | {"uri":"…","name":"…"} |
POST /ai-agent/relay/files/delete | Delete a file from the provider's file store | The 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.
| Field | Type | Checked | Meaning |
|---|---|---|---|
requestId | string | Echoed in the error body and logs | Identifies one model request end to end |
turnId | string | Echoed in the error body and logs | Identifies the user turn; the same across the requests of one tool loop |
runtimeId | string or null | No | Identifier of the runtime instance, when the application has one |
agentId | string | No | Identifier of the agent configuration that owns the conversation |
sessionId | string | No | Identifier of the conversation; one document, tab, or task |
aiProviderConfig | object | aiProvider and model must equal the values in tfAgentTransport | The provider settings the host application believes it is using |
relayVendor | string or null | Must equal tfAgentTransport.vendor when both are present | Vendor selected by the application when the relay serves more than one. A vendor is the name of a provider route configured on the relay |
systemPrompt | string | No | The system prompt in effect for this turn |
transcript | array of transcript messages | No | The conversation before this turn |
input | message content | No | The current user input |
tools | array of tool descriptors | No | The tool catalog offered to the model |
context | object | Shape only: each scope is null or an object, and properties is an object when present | Snapshots 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:
| Shape | Fields |
|---|---|
| Transcript message | role ("user", "assistant", or "tool"), content (string), optional messageContent, requestId, turnId, meta, timestamp |
| Message content | parts: an array of { "type": "text", "text" }, { "type": "image", "source", "mediaType", "fileName" }, or { "type": "file", … } |
| Tool descriptor | name, 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.
| Field | Type | Required | Meaning |
|---|---|---|---|
adapterId | string | Yes | The provider adapter that produced body; see the table below |
aiProvider | string | Yes | Provider family: claude, google, openai, or openai-compatible |
model | string | Send it always | The requested model. A relay may replace it with the model the operator pinned, and some deployments reject a request without it |
vendor | string | Only when the relay serves several vendors for one aiProvider | Name 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 |
stream | boolean | Yes | true asks for the provider's SSE stream; false asks for one JSON body |
pathHint | string | No | Path appended to the configured endpoint; OpenAI-compatible routes only |
query | object of strings | No | Query parameters appended to the provider URL; OpenAI-compatible routes only |
safeHeaders | object of strings | No | Headers the host application considers safe to forward. Relay-owned headers such as the credential always win |
body | object | Yes | The 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:
adapterId | aiProvider | Provider endpoint the relay calls |
|---|---|---|
claude-messages | claude | Messages API |
google-gemini-3, google-gemini-2 | google | generateContent, or streamGenerateContent?alt=sse when stream is true |
openai-chat-completions | openai | Chat Completions |
openai-compatible-chat-completions | openai-compatible | The 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.
| Order | Check | Error code | Status |
|---|---|---|---|
| 1 | Request Origin is allowed | cors-origin-not-allowed | 403 |
| 2 | Body is present, within the size limit, and valid JSON | relay-request-emptyrelay-request-too-largerelay-request-json-invalid | 400413400 |
| 3 | Body, tfAgentRelay, tfAgentTransport, and tfAgentTransport.body are JSON objects; context scopes have the right shape | relay-request-invalid | 400 |
| 4 | adapterId, aiProvider, and stream are present with the right types; model and vendor are strings when present | relay-transport-adapter-missingrelay-transport-provider-missingrelay-transport-model-missingrelay-transport-vendor-invalidrelay-transport-stream-invalid | 400 |
| 5 | tfAgentRelay.aiProviderConfig and tfAgentTransport name the same provider, model, and vendor | relay-provider-mismatchrelay-model-mismatchrelay-vendor-mismatch | 400 |
| 6 | The vendor route exists and matches aiProvider | relay-vendor-not-allowedrelay-provider-not-allowedrelay-vendor-requiredrelay-vendor-provider-mismatchrelay-provider-unsupported | 403403400400400 |
| 7 | The model is in the route's allowlist | relay-model-not-allowed | 403 |
| 8 | The route has a credential and a usable configuration | provider-config-missingprovider-config-invalidrelay-config-unavailable | 500500503 |
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.
| Provider | Where the tool loop reads |
|---|---|
| Claude | content[] blocks with type text or tool_use; stop_reason is tool_use while the model wants tools |
| Gemini | candidates[0].content.parts[] with text or functionCall; candidates[0].finishReason |
| OpenAI and OpenAI-compatible | choices[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
}
}
| Field | Meaning |
|---|---|
error.code | Stable, machine-readable code from the tables on this page |
error.message | Human-readable explanation. Show it to a developer, not to an end user |
error.details | null, or an object with the offending value, for example { "model" }, { "path" }, { "method" }, or { "upstreamStatus" } |
relay.requestId, relay.turnId | Copied from tfAgentRelay when the body could be read; otherwise null |
relay.upstreamStatus | The 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:
| Code | Status | When |
|---|---|---|
upstream-request-failed | 502 | The provider answered with a non-success status. Read relay.upstreamStatus: 401 is the key, 429 is the provider's rate limit |
relay-config-unavailable | 503 | The relay is running but has no usable provider configuration |
relay-internal-error | 500 | An unexpected failure inside the relay |
route-not-found | 404 | Unknown path inside the relay namespace; details.path is the requested path |
relay-method-unsupported | 405 | A 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.
| Endpoint | Request fields | Success response |
|---|---|---|
POST /ai-agent/relay/files/upload | mimeType, base64Body | { "uri", "name" } from the Gemini Files API |
POST /ai-agent/relay/files/delete | provider ("google" or "openai"), then fileUri for Google or fileId for OpenAI | The 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.