Josh Pitzalis

Multi-Turn Conversations with Effect

Before we build anything of consequence, you must internalise an inconvenient truth: the Claude API remembers nothing.

No hidden transcript accumulates on Anthropic's servers. Each request arrives alone, is answered alone, and is forgotten. If you want a conversation with memory, memory is your job.

Conversing with a Goldfish

Suppose you ask Claude, "What is quantum computing?" and receive a perfectly serviceable answer. Pleased, you follow up: "Write another sentence."

Claude dutifully writes another sentence — about migratory birds, perhaps, or the history of cheese. It has no idea what "another" refers to. From its perspective, this is the first thing you have ever said to it.

The follow-up request arrived containing exactly one message. No quantum computing. No context. Just three words.

How Memory Works

The cure has two parts. First, maintain the full list of messages yourself, in an ordinary array. Second, send that entire history with every single request.

You send your initial user message. You take Claude's reply and append it to your array as an assistant message. You append your follow-up as a new user message. Then you send the whole accumulated history back — and Claude, reading it top to bottom, behaves as though it remembered all along.

It is a stage play where you hand the actor the full script before every line. And it works.

Three Small Helpers

Doing this by hand at every call site gets tedious quickly, so we shall write three helper functions and be done with it.

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

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

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

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

const chat = Effect.fn("chat")(function* () {
  const response = yield* LanguageModel.generateText({ prompt: messages });
  return response.text;
});

Prompt.MessageEncoded is @effect/ai's type for a single entry in the conversation: a role of "user" or "assistant", and its content. A conversation is but an array of these.

The interesting one is chat. Notice what it does not contain: no client, no API key, no model name. LanguageModel.generateText summons the model from the surrounding context, and the type checker records that our effect requires a LanguageModel it has not yet been given.

That requirement is not a runtime surprise waiting to happen. It is a fact written into the type, and the program will not compile until someone, somewhere, supplies the model.

The Full Performance

Now let us stage the quantum computing conversation properly.

const program = Effect.gen(function* () {
  // Add the initial user question
  addUserMessage("Define quantum computing in one sentence");

  // Get Claude's response
  const answer = yield* chat();
  yield* Effect.log(answer);

  // Add Claude's response to the conversation history
  addAssistantMessage(answer);

  // Add a follow-up question
  addUserMessage("Write another sentence");

  // Get the follow-up response — with full context this time
  const finalAnswer = yield* chat();
  yield* Effect.log(finalAnswer);
});

By the second chat() call, the messages array holds three entries: your question, Claude's definition, and your follow-up. Claude reads all three and understands precisely what "Write another sentence" means — one more sentence about quantum computing.

Paying the Piper

Our program still owes the type checker a LanguageModel. We settle the 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 conversation runs.

Conveniences

While we were dutifully pushing messages into an array, @effect/ai ships a Chat module — a service that keeps the conversation history in a Ref and appends every exchange on your behalf.

import { Chat } from "@effect/ai";

const program = Effect.gen(function* () {
  const chat = yield* Chat.empty;

  const answer = yield* chat.generateText({
    prompt: "Define quantum computing in one sentence",
  });
  yield* Effect.log(answer.text);

  const finalAnswer = yield* chat.generateText({
    prompt: "Write another sentence",
  });
  yield* Effect.log(finalAnswer.text);
});

No array. No push helpers. No manual appending of the assistant's reply. Chat.empty conjures a fresh conversation, and each generateText call records both your prompt and the model's answer before the next turn begins.

Behold! Our three helpers, obsolete.

Was the manual version a waste, then? No, every chat abstraction you will ever meet, this one included, is an array and a push method wearing a coat.

And now you know what it looks like without the coat.

Repo

Code exercises set up here