The completion endpoint. Its response carries message — the assistant's turn in
the same shape a request takes — which is what makes a tool-calling loop
expressible over HTTP.
Reference#
Requires Authorization: Bearer <SKYL_AUTH_TOKEN>. Returns
application/json.
Request body — ChatRequest#
| Field | Type | JSON | Description |
|---|---|---|---|
| Provider | string | provider | Which registered provider to use. Zero value: the gateway’s default provider |
| Model | string | model | The provider’s model identifier, passed through untouched. Zero value: rejected — the model is required |
| System | string | system | The system prompt. The gateway places it where each provider expects. Zero value: no system prompt |
| Messages | []ChatMessage | messages | The conversation so far. Must not be empty. Zero value: rejected |
| MaxTokens | int | max_tokens | Caps the response length. Zero value: the provider's default, which for Anthropic means skyl supplies 4096 |
| Temperature | *float64 | temperature | Sampling temperature. Sent only when present. Zero value: omitted |
| TopP | *float64 | top_p | Nucleus sampling. Sent only when present. Zero value: omitted |
| Stop | []string | stop | Sequences that end generation. Zero value: none |
| Tools | []ChatTool | tools | Tools the model may call. Zero value: no tools offered |
| ToolChoice | *ChatToolChoice | tool_choice | Constrains tool use: auto, none, required, or a named tool. Zero value: auto |
| Thinking | *ChatThinking | thinking | Requests reasoning. Support varies sharply by provider. Zero value: the provider's default |
| ProviderOptions | map[string]any | provider_options | Vendor-specific fields merged into the outbound payload, overriding anything skyl set. Zero value: nothing extra sent |
ChatMessage#
| Field | Type | JSON | Description |
|---|---|---|---|
| Role | string | role | One of user, assistant, or tool. |
| Text | string | text | Shorthand for a turn whose content is a single run of text. Use it instead of Content for the common case. Zero value: no shorthand text; Content is used |
| Content | []ChatPart | content | Typed content parts. This is what makes a tool-calling loop expressible: an assistant turn containing tool calls can be sent back verbatim. Zero value: no parts; Text is used |
Use text for a plain turn and content for anything typed. Do not set both.
ChatPart#
| Field | Type | JSON | Description |
|---|---|---|---|
| Type | string | type | One of text, image, tool_call, or tool_result. |
| Text | string | text | For type: text. |
| MediaType | string | media_type | For type: image with inline data — the IANA media type, e.g. image/png. |
| Data | string | data | For type: image — base64-encoded image content. |
| URL | string | url | For type: image — a remotely hosted image. Gemini rejects this form. |
| ID | string | id | For type: tool_call — the call identifier the provider generated. |
| Name | string | name | For type: tool_call — the tool the model wants to run. |
| Arguments | json.RawMessage | arguments | For type: tool_call — the JSON object the model produced. |
| ToolCallID | string | tool_call_id | For type: tool_result — must match the tool_call it answers. |
| Content | string | content | For type: tool_result — the tool's output, rendered as text. |
| IsError | bool | is_error | For type: tool_result — reports that the tool failed. Reaches the model faithfully only on Anthropic. |
Response body — ChatResponse#
| Field | Type | JSON | Description |
|---|---|---|---|
| ID | string | id | The provider's response identifier. |
| Provider | string | provider | The adapter that produced this response. |
| Model | string | model | The model that actually served the request, read from the response rather than echoed. |
| Text | string | text | Every text part concatenated — the convenience field for the common case. |
| Message | ChatMessage | message | The assistant's turn, in the same shape a request takes, so it can be appended to your conversation and replayed verbatim. This is what makes the tool loop work over HTTP. |
| StopReason | string | stop_reason | Why generation ended. |
| ToolCalls | []ChatToolCall | tool_calls | The tool calls the model requested, lifted out of Message for convenience. |
| Usage | ChatUsage | usage | Token consumption. |
| Raw | json.RawMessage | raw | The provider's untouched body. Present only when SKYL_INCLUDE_RAW is on. |
Usage#
A simple completion
curl -sS localhost:8080/v1/chat \
-H "Authorization: Bearer $SKYL_AUTH_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"provider": "anthropic",
"model": "claude-opus-5",
"max_tokens": 512,
"messages": [{"role": "user", "text": "Hello"}]
}'curl -sS localhost:8080/v1/chat \
-H "Authorization: Bearer $SKYL_AUTH_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"provider": "anthropic",
"model": "claude-opus-5",
"max_tokens": 512,
"messages": [{"role": "user", "text": "Hello"}]
}'import httpx
r = httpx.post(f"{BASE}/v1/chat", headers=HEADERS, timeout=120.0, json={
"provider": "anthropic",
"model": "claude-opus-5",
"max_tokens": 512,
"messages": [{"role": "user", "text": "Hello"}],
})
r.raise_for_status()
print(r.json()["text"])import httpx
r = httpx.post(f"{BASE}/v1/chat", headers=HEADERS, timeout=120.0, json={
"provider": "anthropic",
"model": "claude-opus-5",
"max_tokens": 512,
"messages": [{"role": "user", "text": "Hello"}],
})
r.raise_for_status()
print(r.json()["text"])const res = await fetch(`${BASE}/v1/chat`, {
method: 'POST',
headers: HEADERS,
body: JSON.stringify({
provider: 'anthropic',
model: 'claude-opus-5',
max_tokens: 512,
messages: [{ role: 'user', text: 'Hello' }],
}),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
console.log((await res.json()).text);const res = await fetch(`${BASE}/v1/chat`, {
method: 'POST',
headers: HEADERS,
body: JSON.stringify({
provider: 'anthropic',
model: 'claude-opus-5',
max_tokens: 512,
messages: [{ role: 'user', text: 'Hello' }],
}),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
console.log((await res.json()).text);{
"id": "msg_01...",
"provider": "anthropic",
"model": "claude-opus-5",
"text": "Hello! How can I help?",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": "Hello! How can I help?"}]
},
"stop_reason": "end_turn",
"usage": {"input_tokens": 9, "output_tokens": 8}
}{
"id": "msg_01...",
"provider": "anthropic",
"model": "claude-opus-5",
"text": "Hello! How can I help?",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": "Hello! How can I help?"}]
},
"stop_reason": "end_turn",
"usage": {"input_tokens": 9, "output_tokens": 8}
}A system prompt and history
{
"provider": "openai",
"model": "gpt-5.6",
"system": "You are a terse Go expert.",
"max_tokens": 256,
"messages": [
{"role": "user", "text": "What is a nil map?"},
{"role": "assistant", "text": "A map that is declared but not allocated."},
{"role": "user", "text": "Can I read from one?"}
]
}{
"provider": "openai",
"model": "gpt-5.6",
"system": "You are a terse Go expert.",
"max_tokens": 256,
"messages": [
{"role": "user", "text": "What is a nil map?"},
{"role": "assistant", "text": "A map that is declared but not allocated."},
{"role": "user", "text": "Can I read from one?"}
]
}A tool-calling round trip
Declare the tool and send the turn:
{
"provider": "anthropic",
"model": "claude-opus-5",
"max_tokens": 512,
"messages": [{"role": "user", "text": "Weather in Kampala?"}],
"tools": [{
"name": "get_weather",
"description": "Get the current weather for a city.",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"]
}
}]
}{
"provider": "anthropic",
"model": "claude-opus-5",
"max_tokens": 512,
"messages": [{"role": "user", "text": "Weather in Kampala?"}],
"tools": [{
"name": "get_weather",
"description": "Get the current weather for a city.",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"]
}
}]
}The response carries the calls and the replayable turn:
{
"stop_reason": "tool_use",
"message": {
"role": "assistant",
"content": [{"type": "tool_call", "id": "c1", "name": "get_weather",
"arguments": {"city": "Kampala"}}]
},
"tool_calls": [{"id": "c1", "name": "get_weather", "arguments": {"city": "Kampala"}}]
}{
"stop_reason": "tool_use",
"message": {
"role": "assistant",
"content": [{"type": "tool_call", "id": "c1", "name": "get_weather",
"arguments": {"city": "Kampala"}}]
},
"tool_calls": [{"id": "c1", "name": "get_weather", "arguments": {"city": "Kampala"}}]
}Send message back verbatim, then the result:
{
"messages": [
{"role": "user", "text": "Weather in Kampala?"},
{"role": "assistant", "content": [{"type": "tool_call", "id": "c1",
"name": "get_weather", "arguments": {"city": "Kampala"}}]},
{"role": "tool", "content": [{"type": "tool_result", "tool_call_id": "c1",
"content": "22C and sunny"}]}
]
}{
"messages": [
{"role": "user", "text": "Weather in Kampala?"},
{"role": "assistant", "content": [{"type": "tool_call", "id": "c1",
"name": "get_weather", "arguments": {"city": "Kampala"}}]},
{"role": "tool", "content": [{"type": "tool_result", "tool_call_id": "c1",
"content": "22C and sunny"}]}
]
}Echoing message back is exactly why it exists: every provider rejects a tool
result that does not follow the call it answers.
An image
{
"messages": [{
"role": "user",
"content": [
{"type": "image", "media_type": "image/png", "data": "<base64>"},
{"type": "text", "text": "What does this chart show?"}
]
}]
}{
"messages": [{
"role": "user",
"content": [
{"type": "image", "media_type": "image/png", "data": "<base64>"},
{"type": "text", "text": "What does this chart show?"}
]
}]
}Over HTTP, data is base64 — unlike the Go API, where Image.Data is raw
bytes.
Errors#
| skyl error | HTTP | kind | Note |
|---|---|---|---|
ErrAuth | 502 | auth | The gateway's own provider credential was rejected. Not 401 — the caller did nothing wrong. |
ErrRateLimit | 429 | rate_limit | Returned only after skyl exhausted its retries upstream. |
ErrNotFound | 404 | not_found | No such model for this account — usually a typo, since model IDs are not validated. |
ErrBadRequest | 400 | bad_request | Malformed request, caught by local validation or by the provider. |
ErrUnsupported | 400 | unsupported | The provider cannot express part of the request — an image URL on Gemini, say. |
ErrRefusal | 422 | refusal | The model or its safety classifiers declined. Do not retry the same request. |
ErrServer | 502 | server | The provider failed on its side. |
(anything else) | 502 | unknown | The default. 502 is the fallback for an unclassified upstream failure. |
And before any upstream call happens:
| HTTP | kind | When |
|---|---|---|
| 401 | auth | The caller presented a missing or wrong bearer token. |
| 400 | bad_request | Invalid JSON, or an unknown field. DisallowUnknownFields is on, so a newer client against an older gateway gets a 400 rather than a silent ignore — upgrade gateways first. |
| 404 | not_found | The named provider is not registered on this gateway. |
The provider's raw error body is never forwarded: it can echo request content back to a caller who should not see it.
Troubleshooting#
400 on a request that used to work
The wire format changed: "content": "hi" became "text": "hi", and
DisallowUnknownFields is on. Update the client.
502 with kind “auth”
The gateway's own provider key was rejected. This is an operator problem, not a
caller one — check the ANTHROPIC_API_KEY / OPENAI_API_KEY in the gateway's
environment.
raw is missing
SKYL_INCLUDE_RAW defaults to false, because the body crosses a trust boundary.
422 with kind “refusal”
The model or its classifiers declined. The request was well-formed and understood — do not retry it unchanged.