Calling Claude From Unity
UnityWebRequest, JSON, and the reply
Verified against a working setup on 2026-08-10
Endpoint at your own proxy and drop the key header. API Key Security explains why in detail.Use UnityWebRequest, not the .NET SDK
Anthropic publishes an official C# SDK, and it is the right choice for a .NET backend — including the proxy you are going to write. It is the wrong choice inside Unity: it targets modern .NET, and Unity's runtime, IL2CPP builds, and AOT platforms make third-party HTTP stacks a reliable source of build-only failures.
The API is plain HTTP and JSON. UnityWebRequest handles it everywhere Unity ships, including WebGL, where HttpClient does not work at all.
The request
| Part | Value | Note |
|---|---|---|
Method / URL | POST /v1/messages | On api.anthropic.com, or your proxy. |
anthropic-version | 2023-06-01 | Required. Not the model version. |
content-type | application/json | Required. |
x-api-key | Your key | On the proxy only. Never in the build. |
model | A model id | See the table below. |
max_tokens | An integer | Required. Caps the reply length. |
messages | Array of turns | Alternating user / assistant. |
system | A string | Optional. Where the character goes. |
Which model
Verified against the Claude API reference, 2026-08-10
| Model id | Per million tokens | For a game |
|---|---|---|
claude-haiku-4-5 | $1 in / $5 out | Fastest and cheapest. Ambient chatter, short replies. |
claude-sonnet-5 | $3 in / $15 out | Balanced. Most conversational NPCs. |
claude-opus-5 | $5 in / $25 out | Most capable. Reserve for moments that matter. |
The C#
using System;
using System.Collections;
using System.Text;
using UnityEngine;
using UnityEngine.Networking;
public class ClaudeClient : MonoBehaviour
{
[SerializeField] private string endpoint = "https://your-proxy.example.com/chat";
[SerializeField] private string model = "claude-haiku-4-5";
[SerializeField] private int maxTokens = 300;
[SerializeField] private float timeoutSeconds = 20f;
[TextArea(3, 6)]
[SerializeField] private string systemPrompt =
"You are a tired blacksmith in a small village. Answer in one or two " +
"short sentences. Never mention that you are an AI.";
public IEnumerator Ask(string playerLine, Action<string> onReply, Action<string> onError)
{
string body = BuildRequestJson(playerLine);
using UnityWebRequest request = new UnityWebRequest(endpoint, "POST");
request.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(body));
request.downloadHandler = new DownloadHandlerBuffer();
request.SetRequestHeader("content-type", "application/json");
request.timeout = Mathf.CeilToInt(timeoutSeconds);
yield return request.SendWebRequest();
if (request.result != UnityWebRequest.Result.Success)
{
// Network down, timeout, or a non-2xx status.
onError?.Invoke(request.error);
yield break;
}
string reply = ExtractText(request.downloadHandler.text);
if (string.IsNullOrEmpty(reply))
onError?.Invoke("Empty reply");
else
onReply?.Invoke(reply);
}
}using UnityWebRequest request = ... disposes the request when the method exits, including on the yield break path. Skipping that leaks native memory once per call, which is invisible in testing and obvious after an hour of play.Building the JSON
JsonUtility can build this shape, because the request is a plain object with plain fields.
[Serializable] private class Turn { public string role; public string content; }
[Serializable]
private class Request
{
public string model;
public int max_tokens;
public string system;
public Turn[] messages;
}
private string BuildRequestJson(string playerLine)
{
Request payload = new Request
{
model = model,
max_tokens = maxTokens,
system = systemPrompt,
messages = new[] { new Turn { role = "user", content = playerLine } }
};
return JsonUtility.ToJson(payload);
}Reading the reply, and why JsonUtility gives up
The response wraps the answer in an array of typed content blocks:
{
"id": "msg_01...",
"model": "claude-haiku-4-5",
"stop_reason": "end_turn",
"content": [
{ "type": "text", "text": "Forge's been cold since Tuesday. What do you need?" }
],
"usage": { "input_tokens": 42, "output_tokens": 17 }
}That content array is polymorphic — blocks are discriminated by type, and text is only one kind. This is exactly the limitation the Saving & Loading topic lists: JsonUtility cannot handle polymorphism, so it will happily deserialise every block into the base shape and silently give you nothing useful.
// Newtonsoft Json.NET ships as a Unity package:
// com.unity.nuget.newtonsoft-json
using Newtonsoft.Json.Linq;
private string ExtractText(string json)
{
JObject root = JObject.Parse(json);
// Concatenate every text block, ignoring any other type
var parts = root["content"]
?.Where(b => (string)b["type"] == "text")
.Select(b => (string)b["text"]);
return parts == null ? null : string.Concat(parts).Trim();
}[Serializable] private class Block { public string type; public string text; }
[Serializable] private class Response { public Block[] content; }
private string ExtractText(string json)
{
// Compiles. Runs. Returns null or empty far too often, because
// JsonUtility cannot express "an array of differently-shaped things"
// and gives no error when it fails to.
Response r = JsonUtility.FromJson<Response>(json);
return r.content[0].text;
}com.unity.nuget.newtonsoft-json. It is a Unity-maintained package, not a random DLL.The cases that are not success
| What happened | How you see it | Do this |
|---|---|---|
Reply was cut off | stop_reason: max_tokens | Raise max_tokens, or ask for shorter replies. |
Model declined | stop_reason: refusal | Use your fallback line. Do not retry as-is. |
Rate limited | HTTP 429 | Back off and retry; your proxy should queue. |
Overloaded | HTTP 529 | Retry with backoff. |
Bad request | HTTP 400 | A bug in your payload. Log it; do not retry. |
No network | Result != Success | Fallback line, immediately. |
stop_reason before you use the text. A reply cut off at max_tokens ends mid-sentence, and an NPC delivering half a line reads as a bug to the player — because it is one.Calling it
StartCoroutine(claude.Ask(
playerLine,
reply => dialogueBox.Show(reply),
error => dialogueBox.Show(FallbackLine()) // always have one
));A coroutine is the right tool here: it is tied to this MonoBehaviour, so destroying the NPC stops the request cleanly. An async method would keep running and then touch a destroyed object — the trap described in Coroutines & Async.
What to take away
- Use
UnityWebRequestin Unity; save the official SDK for your proxy. anthropic-version: 2023-06-01andmax_tokensare both required.JsonUtilitycan build the request but cannot read the response.- Add
com.unity.nuget.newtonsoft-jsonand parse with it. - Check
stop_reasonbefore showing the text. - Dispose the request, and always have a fallback line.