Josh Pitzalis

Structured Data with Effect

Suppose we are building a web app that generates AWS EventBridge rules. A user types a description of the events they want to capture, clicks Generate, and somewhere in our program, LanguageModel.generateText fetches clean JSON for them to copy and use immediately.

We wire the button, deploy, and admire our work. What could possibly go wrong?

The Problem with Default Responses

Claude, you see, wants to be helpful. Ask it for JSON, and it does not hand you JSON. It hands you a small presentation about JSON. response.text arrives looking like this:

```json
{
  "source": ["aws.ec2"],
  "detail-type": ["EC2 Instance State-change Notification"],
  "detail": {
    "state": ["running"]
  }
}
```

This rule captures EC2 instance state changes when instances start running.

The JSON itself is correct. It is also wrapped in a markdown code block and trailed by a friendly sentence of commentary.

So our user clicks Copy, pastes into the AWS console, and gets a parse error—they pasted three backticks, the word json, and a book report. Now they must manually select just the JSON portion, which is precisely the friction our Generate button existed to remove.

How do we get the raw data and nothing else?

Prefilling the Assistant's Answer

The prompt option, it turns out, accepts more than a string. It accepts an array of messages. Nothing forbids the last message from having role: "assistant":

const response = yield* LanguageModel.generateText({
  prompt: [
    {
      role: "user",
      content: "Generate a very short event bridge rule as json",
    },
    { role: "assistant", content: "```json" },
  ],
});

This is called assistant message prefilling. When Claude receives the conversation, it believes it has already begun answering, and that its answer so far consists of an opened code block. All it can do is continue: it writes the JSON content, and only the JSON content.

The newest models, Claude 4.6 onwards, have retired assistant prefill entirely. The Opus models have structured outputs via output_config.format, which constrain the response shape at the API level. So why learn prefilling at all, then? Because the technique still runs on earlier models, and because it is a useful feather to have in your hat: it teaches you how output gets shaped, which is knowledge the fancier instruments quietly depend on.

Stop Sequences

A stop sequence is a string that, the instant it appears in the output, ends generation. It is provider configuration, so we set it the Anthropic way, with withConfigOverride on this one call:

const response = yield* LanguageModel.generateText({
  prompt: [
    {
      role: "user",
      content: "Generate a very short event bridge rule as json",
    },
    { role: "assistant", content: "```json" },
  ],
}).pipe(
  AnthropicLanguageModel.withConfigOverride({ stop_sequences: ["```"] }),
);

Claude reaches for the closing ```, and the gate slams shut mid-backtick. response.text now contains:

{
  "source": ["aws.ec2"],
  "detail-type": ["EC2 Instance State-change Notification"],
  "detail": {
    "state": ["running"]
  }
}

No fences. No commentary. Just the goods.

Processing the Response

The response is not pristine. Sometimes we get stray newline characters, and the text still needs parsing into an actual object:

const rule = yield* Effect.try(() => JSON.parse(response.text.trim()));

trim() removes the loose newlines, and Effect.try wraps the parse — so if Claude ever hands us something that is not JSON after all, the failure lands in the error channel as a typed UnknownException we can handle, rather than an exception detonating mid-program.

Beyond JSON

This technique works anytime we need structured data without commentary: Python code snippets, bulleted lists, CSV data, or any formatted content where we want the content itself, not a lecture about it.

To generalise the recipe, identify what Claude naturally wants to wrap your content in, then use the opening wrapper as your prefill and the closing wrapper as your stop sequence. For code, that is usually a markdown code block; for lists, it may be different formatting markers.

Can't We Just Decode with Schema?

Why not simply decode whatever Claude returns with Schema, and be done with it:

const EventBridgeRule = Schema.Struct({
  source: Schema.Array(Schema.String),
  "detail-type": Schema.Array(Schema.String),
});

const rule = yield* Schema.decodeUnknown(Schema.parseJson(EventBridgeRule))(
  response.text,
);

Decode it into what, exactly?

A schema is a validator. Hand it clean JSON, and it will confirm the shape magnificently. Hand it three backticks and a book report, and it will fail. A beautifully typed ParseError in the error channel, granted, but a failure all the same.

The schema tells us that the output is garbage; our problem is arranging for the output not to be garbage in the first place.

That is what prefilling and stop sequences do: they shape the generation itself. Shape the output with prefill and stop sequences, then decode the result with Schema, and now a ParseError actually means something went wrong rather than something predictable went unhandled.

The Effect Way

The @effect/ai package has LanguageModel.generateObject that takes a schema:

const EventBridgeRule = Schema.Struct({
  source: Schema.Array(Schema.String),
  "detail-type": Schema.Array(Schema.String),
});

const structured = yield* LanguageModel.generateObject({
  prompt: "Generate a very short event bridge rule",
  schema: EventBridgeRule,
});

structured.value; // decoded, validated, and typed by the schema

No prefill, no stop sequence, no trim, no JSON.parse. The model is steered toward the schema's structure, and structured.value arrives already decoded and typed.

Was our hand-rolled version wasted effort, then?

Well, generateObject speaks only JSON-shaped objects. The moment you need a Python snippet, a bulleted list, or CSV, you are back to prefill and stop sequences, and now you own that trick outright.

Second, you now know what lives underneath an abstraction like generateObject: steering on one end, cutting on the other, parsing in between. When it misbehaves someday, you will not be staring at a black box.

Exercise

Use message prefilling and stop sequences to get three different sample AWS CLI commands in a single response. Each command should be very short, and there must be no comments or explanation anywhere in the output.

A hint: message prefilling is not limited to characters like ```. An assistant message can begin with anything.

If you get stuck, the exercise walkthrough on Anthropic Academy shows a solution in Python.

👉 The solution

The hint is doing the heavy lifting here. A prefill can be an entire sentence. One that commits Claude to a course of action as surely as an opened code block does:

const commands = yield* LanguageModel.generateText({
  prompt: [
    {
      role: "user",
      content:
        "Generate three different sample AWS CLI commands. Each should be very short.",
    },
    {
      role: "assistant",
      content:
        "Here are three commands in a single code block without any comments:\n```bash",
    },
  ],
}).pipe(
  AnthropicLanguageModel.withConfigOverride({ stop_sequences: ["```"] }),
);

commands.text.trim();

The prefilled sentence declares that all three commands are coming in one code block, with no comments — and Claude, believing it already said so, obliges. The opened ```bash fence puts it in command-writing mode, and the stop sequence cuts generation the instant it tries to close the fence:

aws s3 ls
aws ec2 describe-instances --region us-east-1
aws iam list-users

Three commands, one response, not a word of commentary. The prefill is Claude's own voice, turned into an instrument of precision.