Chapter 12 of 15 13 min intermediate

Calling Claude From Unity

UnityWebRequest, JSON, and the reply

Verified against a working setup on 2026-08-10

The code here talks to the API directly so the request shape is clear. Do not ship it that way — point 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

PartValueNote
Method / URLPOST /v1/messagesOn api.anthropic.com, or your proxy.
anthropic-version2023-06-01Required. Not the model version.
content-typeapplication/jsonRequired.
x-api-keyYour keyOn the proxy only. Never in the build.
modelA model idSee the table below.
max_tokensAn integerRequired. Caps the reply length.
messagesArray of turnsAlternating user / assistant.
systemA stringOptional. Where the character goes.

Which model

Verified against the Claude API reference, 2026-08-10

Model idPer million tokensFor a game
claude-haiku-4-5$1 in / $5 outFastest and cheapest. Ambient chatter, short replies.
claude-sonnet-5$3 in / $15 outBalanced. Most conversational NPCs.
claude-opus-5$5 in / $25 outMost capable. Reserve for moments that matter.
In a game, latency usually matters more than raw capability — a fast, slightly-less-clever NPC beats a brilliant one the player waits four seconds for. Start with the cheapest model that holds the character and move up only where you can feel the difference.

The C#

ClaudeClient.cs
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.

C#
[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:

Response (trimmed)
json
{
  "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.

C#
// 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();
}
Add Newtonsoft via Window → Package Manager → Add package by name and enter com.unity.nuget.newtonsoft-json. It is a Unity-maintained package, not a random DLL.

The cases that are not success

What happenedHow you see itDo this
Reply was cut offstop_reason: max_tokensRaise max_tokens, or ask for shorter replies.
Model declinedstop_reason: refusalUse your fallback line. Do not retry as-is.
Rate limitedHTTP 429Back off and retry; your proxy should queue.
OverloadedHTTP 529Retry with backoff.
Bad requestHTTP 400A bug in your payload. Log it; do not retry.
No networkResult != SuccessFallback line, immediately.
Check 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

C#
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 UnityWebRequest in Unity; save the official SDK for your proxy.
  • anthropic-version: 2023-06-01 and max_tokens are both required.
  • JsonUtility can build the request but cannot read the response.
  • Add com.unity.nuget.newtonsoft-json and parse with it.
  • Check stop_reason before showing the text.
  • Dispose the request, and always have a fallback line.