Pacific Blue Software Logo

How to Call the Claude API from Delphi

How to Call the Claude API from Delphi

Anthropic publishes official libraries for Python, TypeScript, Java, Go, C#, Ruby and PHP, but not for Delphi. You do not need one. The Claude API is HTTPS and JSON, and Delphi's own THTTPClient and System.JSON are all it takes - no components, no DLLs, no third-party libraries.

This article builds a single unit that sends questions, keeps the conversation, retries when the service is busy, handles refusals, and streams the reply as it is written. The code compiles with Delphi 13 and should work unchanged on any version from Delphi 10.3 on, the first with inline variables.


Before You Start


The Request

Everything goes to one endpoint, https://api.anthropic.com/v1/messages, as a POST with three headers - the key, the API version and the content type - and a JSON body:

{
  "model": "claude-opus-5-5",
  "max_tokens": 16000,
  "system": "You are an experienced Delphi developer.",
  "messages": [
    {"role": "user", "content": "What does inherited do in a constructor?"}
  ]
}

The reply holds a content array of blocks. The answer is in the blocks whose type is text; other blocks, such as thinking, can come before it, so never assume the answer is the first block. The reply also gives the stop_reason and the tokens used, which is what you are billed on.

The API keeps no state between calls. To ask a follow-up question, you send the whole conversation again, with each earlier answer as an assistant message.


The Unit

unit Claude_Client;

// A minimal client for the Claude Messages API using THTTPClient.
// Keeps the conversation so follow-up questions have context, retries
// rate-limit and overload errors, and can stream the reply as it arrives.

interface

uses
  System.SysUtils,
  System.Classes,
  System.JSON,
  System.Net.HttpClient,
  System.Net.URLClient;

type
  EClaude_Error = class(Exception)
  public
    Status: Integer;
    Error_Type: string;
  end;

  TClaude_Text_Event = reference to procedure(const AText: string);

  TClaude_Client = class
  private
    FHttp: THTTPClient;
    FApi_Key: string;
    FMessages: TJSONArray;
    FModel: string;
    FSystem_Prompt: string;
    FEffort: string;
    FMax_Tokens: Integer;
    FMax_Retries: Integer;
    FInput_Tokens: Integer;
    FOutput_Tokens: Integer;
    FStop_Reason: string;
    function Request_Body(AStream: Boolean): TJSONObject;
    function Headers: TNetHeaders;
    function Post(ABody: TJSONObject; AResponse: TStream): IHTTPResponse;
    procedure Raise_Api_Error(const AResponse: IHTTPResponse; const ABody: string);
    function Text_Of(AContent: TJSONArray): string;
  public
    constructor Create(const AApi_Key: string);
    destructor Destroy; override;
    function Ask(const APrompt: string): string;
    function Ask_Streamed(const APrompt: string; const AOn_Text: TClaude_Text_Event): string;
    procedure Clear;
    property Model: string read FModel write FModel;
    property System_Prompt: string read FSystem_Prompt write FSystem_Prompt;
    property Effort: string read FEffort write FEffort;
    property Max_Tokens: Integer read FMax_Tokens write FMax_Tokens;
    property Input_Tokens: Integer read FInput_Tokens;
    property Output_Tokens: Integer read FOutput_Tokens;
    property Stop_Reason: string read FStop_Reason;
  end;

implementation

uses
  System.Math;

const
  Api_Url = 'https://api.anthropic.com/v1/messages';
  Api_Version = '2023-06-01';
  Fallback_Beta = 'server-side-fallback-2026-07-01';
  Default_Model = 'claude-opus-5-5';
  Ten_Minutes = 10 * 60 * 1000;

type
  // Receives the streamed response and raises an event for each text delta.
  // Server-sent events arrive as lines; a chunk can end part way through a
  // line, or part way through a UTF-8 character, so bytes are held until a
  // line feed arrives.
  TSSE_Stream = class(TStream)
  private
    FPending: TBytes;
    FOn_Text: TClaude_Text_Event;
    FText: TStringBuilder;
    FOther: TStringBuilder;
    FStop_Reason: string;
    FInput_Tokens: Integer;
    FOutput_Tokens: Integer;
    FError: string;
    procedure Handle_Line(const ALine: string);
  public
    constructor Create(const AOn_Text: TClaude_Text_Event);
    destructor Destroy; override;
    function Write(const Buffer; Count: Longint): Longint; override;
    function Read(var Buffer; Count: Longint): Longint; override;
    function Seek(const Offset: Int64; Origin: TSeekOrigin): Int64; override;
  end;

{ TSSE_Stream }

constructor TSSE_Stream.Create(const AOn_Text: TClaude_Text_Event);
begin
  inherited Create;
  FOn_Text := AOn_Text;
  FText := TStringBuilder.Create;
  FOther := TStringBuilder.Create;
end;

destructor TSSE_Stream.Destroy;
begin
  FText.Free;
  FOther.Free;
  inherited;
end;

function TSSE_Stream.Read(var Buffer; Count: Longint): Longint;
begin
  Result := 0;
end;

function TSSE_Stream.Seek(const Offset: Int64; Origin: TSeekOrigin): Int64;
begin
  Result := 0;
end;

function TSSE_Stream.Write(const Buffer; Count: Longint): Longint;
var
  LStart, LOld, I: Integer;
  LLine: string;
begin
  LOld := Length(FPending);
  SetLength(FPending, LOld + Count);
  Move(Buffer, FPending[LOld], Count);

  LStart := 0;
  for I := 0 to High(FPending) do
    if FPending[I] = 10 then
    begin
      LLine := TEncoding.UTF8.GetString(FPending, LStart, I - LStart);
      Handle_Line(LLine.TrimRight);
      LStart := I + 1;
    end;
  FPending := Copy(FPending, LStart, Length(FPending) - LStart);
  Result := Count;
end;

procedure TSSE_Stream.Handle_Line(const ALine: string);
var
  LEvent, LDelta: TJSONObject;
  LType, LText: string;
begin
  // An error response is plain JSON, not events: keep it for the message
  if not ALine.StartsWith('data:') then
  begin
    if not ALine.StartsWith('event:') then
      FOther.Append(ALine);
    Exit;
  end;
  LEvent := TJSONObject.ParseJSONValue(ALine.Substring(5).Trim) as TJSONObject;
  if LEvent = nil then
    Exit;
  try
    LType := LEvent.GetValue<string>('type', '');
    if LType = 'content_block_delta' then
    begin
      LDelta := LEvent.GetValue<TJSONObject>('delta');
      if LDelta.GetValue<string>('type', '') = 'text_delta' then
      begin
        LText := LDelta.GetValue<string>('text', '');
        FText.Append(LText);
        if Assigned(FOn_Text) then
          FOn_Text(LText);
      end;
    end
    else if LType = 'message_start' then
      FInput_Tokens := LEvent.GetValue<Integer>('message.usage.input_tokens', 0)
    else if LType = 'message_delta' then
    begin
      FStop_Reason := LEvent.GetValue<string>('delta.stop_reason', '');
      FOutput_Tokens := LEvent.GetValue<Integer>('usage.output_tokens', 0);
    end
    else if LType = 'error' then
      FError := LEvent.GetValue<string>('error.message', 'Stream error');
  finally
    LEvent.Free;
  end;
end;

{ TClaude_Client }

constructor TClaude_Client.Create(const AApi_Key: string);
begin
  inherited Create;
  FApi_Key := AApi_Key;
  FModel := Default_Model;
  FEffort := 'medium';
  FMax_Tokens := 16000;
  FMax_Retries := 3;
  FMessages := TJSONArray.Create;
  FHttp := THTTPClient.Create;
  FHttp.ResponseTimeout := Ten_Minutes;
end;

destructor TClaude_Client.Destroy;
begin
  FHttp.Free;
  FMessages.Free;
  inherited;
end;

procedure TClaude_Client.Clear;
begin
  FMessages.Free;
  FMessages := TJSONArray.Create;
end;

function TClaude_Client.Headers: TNetHeaders;
begin
  Result := [TNetHeader.Create('x-api-key', FApi_Key),
             TNetHeader.Create('anthropic-version', Api_Version),
             TNetHeader.Create('anthropic-beta', Fallback_Beta),
             TNetHeader.Create('content-type', 'application/json')];
end;

function TClaude_Client.Request_Body(AStream: Boolean): TJSONObject;
begin
  Result := TJSONObject.Create;
  Result.AddPair('model', FModel);
  Result.AddPair('max_tokens', TJSONNumber.Create(FMax_Tokens));
  if FSystem_Prompt <> '' then
    Result.AddPair('system', FSystem_Prompt);
  Result.AddPair('output_config', TJSONObject.Create(TJSONPair.Create('effort', FEffort)));
  Result.AddPair('fallbacks', 'default');
  if AStream then
    Result.AddPair('stream', TJSONBool.Create(True));
  Result.AddPair('messages', FMessages.Clone as TJSONArray);
end;

function TClaude_Client.Post(ABody: TJSONObject; AResponse: TStream): IHTTPResponse;
var
  LSource: TStringStream;
  LAttempt, LWait: Integer;
begin
  LSource := TStringStream.Create(ABody.ToJSON, TEncoding.UTF8);
  try
    LAttempt := 0;
    repeat
      LSource.Position := 0;
      AResponse.Size := 0;
      Result := FHttp.Post(Api_Url, LSource, AResponse, Headers);
      // 429 rate limited, 529 overloaded, 5xx server errors: wait and retry
      if not ((Result.StatusCode = 429) or (Result.StatusCode >= 500)) or (LAttempt = FMax_Retries) then
        Exit;
      LWait := StrToIntDef(Result.HeaderValue['retry-after'], 0) * 1000;
      if LWait = 0 then
        LWait := 1000 shl LAttempt;
      Sleep(Min(LWait, 60000));
      Inc(LAttempt);
    until False;
  finally
    LSource.Free;
  end;
end;

procedure TClaude_Client.Raise_Api_Error(const AResponse: IHTTPResponse; const ABody: string);
var
  LJson: TJSONValue;
  LError: EClaude_Error;
  LMessage, LType: string;
begin
  LMessage := AResponse.StatusText;
  LType := '';
  LJson := TJSONObject.ParseJSONValue(ABody);
  try
    if LJson <> nil then
    begin
      LMessage := LJson.GetValue<string>('error.message', LMessage);
      LType := LJson.GetValue<string>('error.type', '');
    end;
  finally
    LJson.Free;
  end;
  LError := EClaude_Error.CreateFmt('Claude API error %d: %s', [AResponse.StatusCode, LMessage]);
  LError.Status := AResponse.StatusCode;
  LError.Error_Type := LType;
  raise LError;
end;

function TClaude_Client.Text_Of(AContent: TJSONArray): string;
var
  LBlock: TJSONValue;
begin
  Result := '';
  for LBlock in AContent do
    if LBlock.GetValue<string>('type', '') = 'text' then
      Result := Result + LBlock.GetValue<string>('text', '');
end;

function TClaude_Client.Ask(const APrompt: string): string;
var
  LBody, LReply: TJSONObject;
  LContent: TJSONArray;
  LResponse: TStringStream;
  LHttp_Response: IHTTPResponse;
begin
  FMessages.AddElement(TJSONObject.Create
    .AddPair('role', 'user')
    .AddPair('content', APrompt));
  LBody := Request_Body(False);
  LResponse := TStringStream.Create('', TEncoding.UTF8);
  try
    LHttp_Response := Post(LBody, LResponse);
    if LHttp_Response.StatusCode <> 200 then
    begin
      FMessages.Remove(FMessages.Count - 1).Free;
      Raise_Api_Error(LHttp_Response, LResponse.DataString);
    end;

    LReply := TJSONObject.ParseJSONValue(LResponse.DataString) as TJSONObject;
    try
      FStop_Reason := LReply.GetValue<string>('stop_reason', '');
      FInput_Tokens := LReply.GetValue<Integer>('usage.input_tokens', 0);
      FOutput_Tokens := LReply.GetValue<Integer>('usage.output_tokens', 0);
      LContent := LReply.GetValue<TJSONArray>('content');
      Result := Text_Of(LContent);
      // Keep the whole content array, thinking blocks included, unchanged:
      // the API expects earlier turns to come back exactly as it sent them
      FMessages.AddElement(TJSONObject.Create
        .AddPair('role', 'assistant')
        .AddPair('content', LContent.Clone as TJSONArray));
    finally
      LReply.Free;
    end;
  finally
    LResponse.Free;
    LBody.Free;
  end;
end;

function TClaude_Client.Ask_Streamed(const APrompt: string; const AOn_Text: TClaude_Text_Event): string;
var
  LMessages: TJSONArray;
  LBody: TJSONObject;
  LStream: TSSE_Stream;
  LHttp_Response: IHTTPResponse;
const
  LF: Byte = 10;
begin
  // A streamed question is sent on its own, without the conversation, so
  // the history never holds a reply that was rebuilt from fragments
  LMessages := FMessages;
  FMessages := TJSONArray.Create;
  try
    FMessages.AddElement(TJSONObject.Create
      .AddPair('role', 'user')
      .AddPair('content', APrompt));
    LBody := Request_Body(True);
  finally
    FMessages.Free;
    FMessages := LMessages;
  end;

  LStream := TSSE_Stream.Create(AOn_Text);
  try
    LHttp_Response := Post(LBody, LStream);
    if LHttp_Response.StatusCode <> 200 then
    begin
      LStream.Write(LF, 1);   // the body may not end with a line feed
      Raise_Api_Error(LHttp_Response, LStream.FOther.ToString);
    end;
    if LStream.FError <> '' then
      raise EClaude_Error.Create(LStream.FError);
    FStop_Reason := LStream.FStop_Reason;
    FInput_Tokens := LStream.FInput_Tokens;
    FOutput_Tokens := LStream.FOutput_Tokens;
    Result := LStream.FText.ToString;
  finally
    LStream.Free;
    LBody.Free;
  end;
end;

end.

How It Works

The conversation

FMessages holds the conversation. Ask adds the question, sends the lot, and adds the reply's whole content array as the next assistant message. It must be the whole array, unchanged: the current models return thinking blocks with a signature, and the API checks that earlier turns come back exactly as it sent them. Rebuilding the reply from its text alone would break that. Clear starts a new conversation.

Every request resends the conversation, so long conversations cost more per question. Start a new one when the subject changes.

Effort

The effort setting - low, medium, high, xhigh or max - controls how much the model thinks before answering, and so the time and tokens a reply takes. On Claude Opus 5.5 thinking is always on and effort is the way to control it. medium suits most questions; use low for simple lookups and high or above for hard problems.

Choosing a model

The unit defaults to claude-opus-5-5. Set Model to claude-sonnet-5-5 or claude-haiku-5-5 for cheaper, faster replies. At the time of writing, Haiku costs about one fortieth of Opus per token, and is good enough for classifying, extracting and summarising. Always use a model ID exactly as Anthropic publishes it.

Retries

Three kinds of error are worth retrying: 429 (you have hit your rate limit), 529 (the service is overloaded) and other 5xx server errors. Post waits for the number of seconds in the retry-after header if there is one, and otherwise for 1, 2 and 4 seconds, then gives up. Anything else - a bad request, a wrong key, no credit - fails straight away with an EClaude_Error holding the status and the API's own error type and message.

Refusals

A request the model declines is not an HTTP error. It returns 200 with a stop_reason of refusal, sometimes with no text at all. Check Stop_Reason before treating an empty reply as an answer. The request also sets "fallbacks": "default" (with the matching anthropic-beta header): if the model declines because a safety classifier flagged the request, the API retries it on another model and returns that answer instead. Legitimate requests are flagged now and then, usually in security or biology topics, so this is worth having.

Streaming

A long answer can take a minute or more. With "stream": true the API sends the reply as server-sent events while it is being written: lines starting event: and data:, the data being a small JSON object. The text arrives in content_block_delta events.

THTTPClient writes the response into whatever stream you give it, as it arrives. TSSE_Stream is a stream that, instead of storing the data, splits it into lines and raises an event for each piece of text. A network chunk can end in the middle of a line - or in the middle of a multi-byte UTF-8 character - so it keeps the bytes until a whole line has arrived before decoding it. Tested with the same data split into chunks of every size from 1 to 7 bytes, including accented characters and an emoji, the result was the same each time.

Ask_Streamed sends one question on its own, outside the conversation. Putting a streamed reply into the conversation would mean rebuilding the thinking blocks and their signatures from the events, which is possible but more code than this article needs.

Timeouts

THTTPClient gives up waiting for a response after 60 seconds by default. A non-streamed answer with a high max_tokens can take longer than that, so the unit sets ResponseTimeout to ten minutes. For long answers, prefer streaming.


Using It

program Claude_Demo;

{$APPTYPE CONSOLE}

uses
  System.SysUtils,
  Claude_Client in 'Claude_Client.pas';

var
  LClaude: TClaude_Client;
  LKey: string;
begin
  LKey := GetEnvironmentVariable('ANTHROPIC_API_KEY');
  if LKey = '' then
  begin
    WriteLn('Set ANTHROPIC_API_KEY first.');
    ExitCode := 1;
    Exit;
  end;

  LClaude := TClaude_Client.Create(LKey);
  try
    try
      LClaude.System_Prompt := 'You are an experienced Delphi developer. Answer briefly.';

      WriteLn(LClaude.Ask('What does the "inherited" keyword do in a constructor?'));
      WriteLn(Format('[%s, %d in, %d out]', [LClaude.Stop_Reason, LClaude.Input_Tokens, LClaude.Output_Tokens]));
      WriteLn;

      // The follow-up only makes sense with the first answer in the conversation
      WriteLn(LClaude.Ask('And what happens if I leave it out?'));
      WriteLn;

      LClaude.Ask_Streamed('Write a haiku about Delphi.',
        procedure(const AText: string)
        begin
          Write(AText);
        end);
      WriteLn;
    except
      on E: EClaude_Error do
        WriteLn(E.Message);
    end;
  finally
    LClaude.Free;
  end;
end.

The second question only makes sense with the first answer in the conversation, which is how you can tell the history is being sent.


From a VCL Form

A request blocks the thread that makes it, so in a GUI program call it from a background task and hand the text back to the main thread with TThread.Queue:

procedure TMain_Form.Ask_Button_Click(Sender: TObject);
var
  LPrompt: string;
begin
  LPrompt := Prompt_Memo.Text;
  Ask_Button.Enabled := False;
  Answer_Memo.Clear;
  TTask.Run(
    procedure
    begin
      try
        try
          FClaude.Ask_Streamed(LPrompt,
            procedure(const AText: string)
            begin
              TThread.Queue(nil,
                procedure
                begin
                  Answer_Memo.SelStart := Answer_Memo.GetTextLen;
                  Answer_Memo.SelText := AText;
                end);
            end);
        except
          on E: Exception do
          begin
            var LMessage := E.Message;
            TThread.Queue(nil,
              procedure
              begin
                ShowMessage(LMessage);
              end);
          end;
        end;
      finally
        TThread.Queue(nil,
          procedure
          begin
            Ask_Button.Enabled := True;
          end);
      end;
    end);
end;

Copy the exception message into a local before queueing it: the exception object is freed as soon as the except block ends, before the queued code runs. One TClaude_Client handles one request at a time, which disabling the button guarantees here; for several at once, create one client per task.


Summary


Useful References


PBS Delphi - A Claude API Client in One Unit


Back to Articles for Developers
Back to Articles on AI
Back to Articles on Delphi
How to Write an MCP Server in Delphi