Josh Pitzalis

System Prompts with Effect

Suppose we are building a math tutor chatbot. A student arrives, anxious and hopeful, and types: "How do I solve 5x + 2 = 3 for x?"

And Claude, catastrophically helpful as always, hands over the complete step-by-step solution. Subtract 2 from both sides, divide by 5, here is your answer, have a lovely day.

The student copies it down, learns nothing, and fails the exam.

What a Tutor Actually Does

A real tutor gives hints before solutions. A real tutor walks the student through the problem step by step, patiently, and demonstrates with similar problems rather than solving the one on the worksheet.

And there are things a real tutor never does: like blurt out the answer, or tell the student to go use a calculator.

The problem is not what Claude knows. The problem is how Claude behaves. System prompts let us calibrate Claude's behaviour without changing the student's question.

The System Prompt

A system prompt offers guidance on how to respond. It's handed to Claude before the conversation begins, and Claude will try to respond the way someone in the specified role would. It also helps keep Claude on task, message after message.

const system = `
You are a patient math tutor.
Do not directly answer a student's questions.
Guide them to a solution step by step.
`;

The shape is conventional: the first line assigns the role — "You are a patient math tutor" — and the lines after it give specific behavioural instructions. Note what the system prompt does not contain: mathematics.

It controls how Claude responds, never what it responds about.

Just Another Message

Anthropic's HTTP API treats the system prompt as a separate top-level field, apart from the messages — a field that must not be empty or null, which condemns callers to conditional gymnastics whenever the prompt is optional.

@effect/ai declines to inherit this awkwardness. In its Prompt model, a system prompt is simply a message with role: "system", sitting in the same array as everything else. The Anthropic provider quietly lifts it out into the API's separate field on the way over the wire.

import { LanguageModel, type Prompt } from "@effect/ai";
import { Effect } from "effect";

const messages: Prompt.MessageEncoded[] = [];

function addUserMessage(text: string) {
  messages.push({ role: "user", content: text });
}

const chat = Effect.fn("chat")(function* (system?: string) {
  const prompt: Prompt.MessageEncoded[] = system
    ? [{ role: "system", content: system }, ...messages]
    : messages;
  const response = yield* LanguageModel.generateText({ prompt });
  return response.text;
});

chat takes the system prompt as an optional parameter. When one is provided, we prepend it to the conversation as a system message; when it is not, we send the messages untouched.

Notice also that chat does not contain a client, API key, or model name. LanguageModel.generateText summons the model from the surrounding context. The type checker notes that our effect requires a LanguageModel it has not yet been given.

Both Worlds on Demand

Now let us run this experiment: the same question, with and without the tutor's temperament.

const program = Effect.gen(function* () {
  addUserMessage("How do I solve 5x + 2 = 3 for x?");

  // Without a system prompt — Claude hands over the full solution
  const answer = yield* chat();
  yield* Effect.log(answer);

  // With a system prompt — Claude becomes a tutor
  const system = `
You are a patient math tutor.
Do not directly answer a student's questions.
Guide them to a solution step by step.
`;

  const tutorAnswer = yield* chat(system);
  yield* Effect.log(tutorAnswer);
});

Without the system prompt, Claude solves the equation outright. With it, Claude asks: "What do you think would be a good first step to isolate x? Consider what operation we might need to perform on both sides to start moving terms around."

Same model. Same question.

Paying the Piper

Our program still owes the type checker a LanguageModel.

We shall settle this debt with layers.

import { AnthropicClient, AnthropicLanguageModel } from "@effect/ai-anthropic";
import { FetchHttpClient } from "@effect/platform";
import { Config, Layer } from "effect";

const SonnetLayer = AnthropicLanguageModel.model("claude-sonnet-5", {
  max_tokens: 1000,
});

const AnthropicLayer = AnthropicClient.layerConfig({
  apiKey: Config.redacted("ANTHROPIC_API_KEY"),
}).pipe(Layer.provide(FetchHttpClient.layer));

await program.pipe(
  Effect.provide(SonnetLayer),
  Effect.provide(AnthropicLayer),
  Effect.runPromise,
);

The model layer names the model, the client layer holds the redacted key, and Effect.provide wires them in at the edge. The requirement vanishes from the type, the program compiles, and the tutor reports for duty.

Conveniences

I must confess that while we were hand-assembling that prompt array, the Prompt module already offers the finished garment. Prompt.setSystem takes a prompt and a string, and returns a new prompt with that string installed as the system message — removing and replacing any system message that came before.

import { LanguageModel, Prompt } from "@effect/ai";

const chat = Effect.fn("chat")(function* (system?: string) {
  const prompt = system
    ? Prompt.make(messages).pipe(Prompt.setSystem(system))
    : Prompt.make(messages);
  const response = yield* LanguageModel.generateText({ prompt });
  return response.text;
});

Prompt.make wraps our raw array in a proper Prompt, and Prompt.setSystem does precisely what our spread expression did. With the added courtesy of evicting any earlier system message rather than stacking a second one on top.

Was the manual version a waste, then? Not remotely. setSystem is an array and a prepend wearing a trenchcoat, and now you know what things look like backstage.

Repo

Code exercises set up here