DEV Community

Cover image for How to get consistent structured output from Claude
Kees Heuperman
Kees Heuperman

Posted on • Edited on

How to get consistent structured output from Claude

Using Anthropic's excellent Claude models in automated workflows can be tricky. Although they excel at conversational interaction, getting consistently formatted output that can be used programmatically requires some work. Unlike OpenAI's models, Claude does not have an option to request structured data, but it is possible with a simple trick.

In this tutorial we'll build a reusable component that calls Claude Haiku and returns structured data following a schema. We'll then use this component to create a workflow for checking user comments for inappropriate content.

This tutorial assumes you are familiar with Node.js and TypeScript.

LLM output is hard to parse

Large Language Models (LLMs) bias towards responding in human readable text and tend to be verbose, often giving additional context even when asked for specific output. This is great for casual chats, but becomes a problem when we try to use LLM responses in workflows where the output feeds into subsequent tasks and needs to be parsed programmatically.

For example, we might to use an LLM to sort customer support messages by category and urgency. To label the messages correctly we would need the LLM to return the category title and urgency score on a predefined scale. This could be achieved by custom prompts requesting a structured response, but there is no guarantee the model will follow the instructions for the output exactly.

Utilising Tool Use to get structured data

Fortunately there is a simple trick we can use to get consistent structured output from Anthropic's Claude models: create a tool spec for Claude with a defined input schema and force the model to use this tool. This will ensure the response follows the specified format. We can generate this input schema using Zod, a TypeScript-first schema library, which we can also use to validate the LLM output and infer TypeScript types.

Setting up the project

Before we write any code, we need to do some set up.

Creating an account with Anthropic

To make calls to the Anthropic API, you will need to create an account on the Anthropic API console and add some credit. Five USD will be more than enough for this tutorial.

Creating project folder

Create a folder for the project and change the current working directory to this folder.

mkdir claude-structured-output && cd claude-structured-output
Enter fullscreen mode Exit fullscreen mode

Installing dependencies

Install pnpm if you don't have it already. You can install it using Corepack which is included with Node.js.

corepack enable pnpm
Enter fullscreen mode Exit fullscreen mode

Then initialise pnpm and install the required dependencies.

pnpm init && pnpm add @anthropic-ai/sdk dotenv && pnpm add -D @types/node
Enter fullscreen mode Exit fullscreen mode

Setting up env variables and TypeScript config

Create an file to safely store env variables and add your Anthropic API key, which you can find in the Anthropic console.

echo "ANTHROPIC_API_KEY=[YOUR API KEY HERE]" > .env
Enter fullscreen mode Exit fullscreen mode

We'll also add some basic configuration for TypeScript. First create the config file.

touch tsconfig.json
Enter fullscreen mode Exit fullscreen mode

Then add the following rules to tsconfig.json.

{
  "compilerOptions": {
    "module": "esnext",
    "moduleResolution": "node",
    "esModuleInterop": true,
    "target": "esnext",
    "types": ["node"]
  },
  "include": ["**/*.ts"]
}
Enter fullscreen mode Exit fullscreen mode

Making a request

Before we look into using a schema for structured output, let's create a basic component and a workflow script to make an LLM call. We can inspect the result and see why specifying a consistently structured output would be useful.

Creating a basic component for LLM calls

Now that the project has been set up, we can create a class to call the Anthropic API. The class constructor creates a new client, which we can then use to send messages to the API via the call function.

touch model.ts
Enter fullscreen mode Exit fullscreen mode
// model.ts

import Anthropic from "@anthropic-ai/sdk";

export default class Model {
  private client: Anthropic;

  constructor(apiKey: string) {
    this.client = new Anthropic({ apiKey });
  }

  async call(message: string) {
    console.log("[LLM] Getting LLM API response");
    const response = await this.client.messages.create({
      max_tokens: 1024,
      model: "claude-3-5-haiku-latest",
      messages: [{ role: "user", content: message }],
      temperature: 0, // minimal randomness injected into the response
    });

    const result = response.content[0];

    return result.type === "text" ? result.text : result.input;
  }
}
Enter fullscreen mode Exit fullscreen mode

Using the component to analyse a message

We can instantiate the class, send a message to the API and log the output to see what it looks like. Note that because we're using an LLM to analyse the messages, our criteria for what is and isn't appropriate can easily be customised to fit our needs.

touch main.ts
Enter fullscreen mode Exit fullscreen mode
// main.ts

import "dotenv/config";
import Model from "./model";

const instructions =
  "Check the following comment for inappropriate content, meaning foul language, harassment, or anti-union sentiment. If such content is detected, specify the type(s) of inappropriate content, explain the reasoning behind the assessment, and provide a confidence score.";

const comment =
  "These idiots deserve it. Should have spent more time working and less time on TikTok";

const model = new Model(process.env.ANTHROPIC_API_KEY || "");

const response = await model.call(`${instructions}\n\n${comment}`);

console.log("[Main] Comment analysed: ", {
  message: comment,
  response,
});
Enter fullscreen mode Exit fullscreen mode

Inspecting the output

Run the project and have a look at the output.

npx tsx main.ts
Enter fullscreen mode Exit fullscreen mode

You will see that although we get a detailed and likely correct analysis in response, the format of the response is unpredictable and would require a human (or an LLM) to parse. This is not great if we want to use the response to take some automatic actions such a removing or labelling inappropriate posts. To get a response in a more useful format, let's give Claude a tool to use.

Requesting a structured response

Managing output structure using a schema

First we'll add the Zod library, and a library to generate JSON schemas from Zod schemas.

pnpm add zod zod-to-json-schema
Enter fullscreen mode Exit fullscreen mode

Now update the model to accept a Zod schema. The tool input specification requires a JSON schema, which we can generate from the Zod schema. Then we pass the tool spec as an argument in the LLM API call, along with the tool_choice parameter, telling Claude to use our tool. When we receive a response we can use the Zod schema to check if it conforms to the expected format and throw an error if it doesn't. Finally, we can infer the type from the Zod schema and assert the return type of the call.

// model.ts

import Anthropic from "@anthropic-ai/sdk";
import type { Tool } from "@anthropic-ai/sdk/resources/index";
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";

export default class Model {
  private client: Anthropic;

  constructor(apiKey: string) {
    this.client = new Anthropic({ apiKey });
  }

  async call<T extends z.ZodTypeAny>({
    message,
    schema,
  }: {
    message: string;
    schema: T;
  }) {
    const jsonSchema = zodToJsonSchema(schema, "schema");
    const schemaDefinition = jsonSchema.definitions?.schema;

    if (!schemaDefinition) {
      console.error(jsonSchema);
      throw new Error("Failed to generate JSON schema for provided schema.");
    }

    const tools = [
      {
        name: "json",
        description: "Respond with a JSON object.",
        input_schema: schemaDefinition as Tool.InputSchema,
      },
    ];

    console.log("[LLM] Getting LLM API response");
    const response = await this.client.messages.create({
      max_tokens: 1024,
      model: "claude-3-5-haiku-latest",
      messages: [{ role: "user", content: message }],
      tools,
      tool_choice: { name: "json", type: "tool" },
      temperature: 0,
    });

    if (response.content[0]?.type !== "tool_use") {
      console.error(response);
      throw new Error("Unexpected response from LLM API.");
    }

    const result = schema.safeParse(response.content[0].input);

    if (result.success === false) {
      console.error(result.error);
      throw new Error("Response did not conform to provided schema.");
    }

    return result.data as z.infer<T>;
  }
}
Enter fullscreen mode Exit fullscreen mode

Defining and passing the schema

We'll need to add the schema and update the model call in the main function. We'll ask the model to flag if the content is inappropriate, give the reason for the assessment so it can be checked by a human if needed and tell us how confident it is in the assessment. We'll also ask it to return the type of violations committed. Let's also add a few more comments to analyse and loop over them, making a call for each one.

// main.ts

import "dotenv/config";
import { z } from "zod";
import Model from "./model";

const comments = [
  "This company is a f*cking joke. They just announced record profits and now they’re firing people??",
  "These layoffs are long overdue. The unions have made people lazy and complacent.",
  "I went through layoffs last year and it was a horrible time for me and my family.",
  "These idiots deserve it. Should have spent more time working and less time on TikTok",
];

const schema = z.object({
  isInappropriateContent: z.boolean(),
  reason: z.string(),
  confidence: z.number().min(0).max(1),
  type: z.object({
    foulLanguage: z.boolean(),
    harassment: z.boolean(),
    antiUnionSentiment: z.boolean(),
  }),
});

const instructions =
  "Check the following comment for inappropriate content, meaning foul language, harassment, or anti-union sentiment. If such content is detected, specify the type(s) of inappropriate content, explain the reasoning behind the assessment, and provide a confidence score.";

const model = new Model(process.env.ANTHROPIC_API_KEY || "");

for (const comment of comments) {
  const response = await model.call({
    message: `${instructions}\n\n${comment}`,
    schema,
  });

  console.log("[Main] Comment analysed: ", {
    message: comment,
    response,
  });
}
Enter fullscreen mode Exit fullscreen mode

Inspecting structured output

Run the project again. You should be getting consistently formatted JSON output for each analysed comment.

npx tsx main.ts
Enter fullscreen mode Exit fullscreen mode

Conclusion

Now we have a simple and reliable way to get consistent JSON output from Claude, which enables us to integrate LLM calls into automated workflows.

Key takeaways:

  1. Default LLM responses are great for conversational interaction, but hard to parse programmatically, making them unsuitable for automated workflows.
  2. By specifying a JSON schema as the input for a tool and instructing Claude to use that tool, we can consistently get output conforming to our required structure.
  3. Using Zod we can specify schemas, validate response structure and get correctly typed return values, improving confidence and developer experience.

Further reading

Top comments (0)