Skip to content

Effect AI

Effect’s @effect/ai package gives you a single LanguageModel.LanguageModel service that every model provider (OpenAI, Anthropic, Workers AI, …) ships as a Layer. Once that layer is in scope, the rest of the surface (generateText, streamText, Toolkit, Chat, …) is provider-agnostic.

This guide is about the Alchemy side of that picture: how to build the LanguageModel layer inside a Platform (Cloudflare.Worker, AWS.Lambda.Function), where to read the API key from, and how to plug Chat.Persistence into a backing store that matches the platform. For the AI calls themselves — LanguageModel.generateText, streamText, Toolkit, structured outputs — refer to Effect’s AI documentation.

Every Effect AI provider is two stacked layers:

  1. A client Layer that holds the API key and HTTP transport (OpenAiClient.layer({ apiKey }), AnthropicClient.layer({ apiKey }), …).
  2. A language model Layer that picks the model and any defaults (OpenAiLanguageModel.layer({ model: "gpt-4o-mini" }), …).

You build that stack once in the Platform’s init phase and Effect.provide(...) it on the handler:

Effect.gen(function* () {
// 1. Init: build the LanguageModel layer (deploy bindings happen here).
const languageModel = ...;
return {
// 2. Exec: per-request handler with the model available.
fetch: Effect.gen(function* () {
const response = yield* LanguageModel.generateText({ prompt });
return HttpServerResponse.json({ text: response.text });
}).pipe(Effect.provide(languageModel)),
};
});

fetch is just one example — the same Effect.provide(languageModel) pattern works in a Lambda handler, an HTTP API endpoint, an RPC procedure, a Workflow, or any other Effect.

Every upstream provider’s client Layer (OpenAiClient.layer, AnthropicClient.layer, …) takes an apiKey: Redacted<string>. Alchemy.Secret deploys the value as a secret_text binding (Cloudflare) or environment variable (Lambda) and gives you a typed runtime accessor that resolves to exactly that Redacted<string>:

import * as Alchemy from "alchemy";
// outer init — registers the binding, returns an accessor
const openAiKey = yield* Alchemy.Secret("OPENAI_API_KEY");
// inside any Effect that needs the value:
// const apiKey = yield* openAiKey // Redacted<string>

The 1-arg form reads OPENAI_API_KEY from your ConfigProvider at deploy time. See the secrets guide for .env, alternate sources, and the difference vs Alchemy.Variable.

The Redacted<string> only exists at runtime — that means the model Layer can’t be built eagerly. Wrap it in Layer.unwrap so the accessor is resolved when the layer is actually used:

import * as Layer from "effect/Layer";
const languageModel = Layer.unwrap(
Effect.gen(function* () {
const apiKey = yield* openAiKey;
return OpenAiLanguageModel.layer({ model: "gpt-4o-mini" }).pipe(
Layer.provide(OpenAiClient.layer({ apiKey })),
Layer.provide(FetchHttpClient.layer),
);
}),
);

FetchHttpClient.layer plugs the runtime fetch API into the Effect HTTP client that every provider’s client Layer depends on:

src/Worker.ts
import * as Alchemy from "alchemy";
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import { LanguageModel } from "effect/unstable/ai";
import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
import { OpenAiClient, OpenAiLanguageModel } from "@effect/ai-openai";
export default Cloudflare.Worker(
"Worker",
{ main: import.meta.path },
Effect.gen(function* () {
const openAiKey = yield* Alchemy.Secret("OPENAI_API_KEY");
const languageModel = Layer.unwrap(
Effect.gen(function* () {
const apiKey = yield* openAiKey;
return OpenAiLanguageModel.layer({ model: "gpt-4o-mini" }).pipe(
Layer.provide(OpenAiClient.layer({ apiKey })),
Layer.provide(FetchHttpClient.layer),
);
}),
);
return {
fetch: Effect.gen(function* () {
const response = yield* LanguageModel.generateText({
prompt: "Say hello.",
}).pipe(Effect.orDie);
return yield* HttpServerResponse.json({ text: response.text });
}).pipe(Effect.provide(languageModel)),
};
}),
);

The only thing that changes is the platform wrapper. The init phase, the layer construction, and the way you provide it to the handler are identical:

src/Lambda.ts
import * as Alchemy from "alchemy";
import * as AWS from "alchemy/AWS";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import { LanguageModel } from "effect/unstable/ai";
import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
import { OpenAiClient, OpenAiLanguageModel } from "@effect/ai-openai";
export default AWS.Lambda.Function(
"Api",
{ main: import.meta.path },
Effect.gen(function* () {
const openAiKey = yield* Alchemy.Secret("OPENAI_API_KEY");
const languageModel = Layer.unwrap(
Effect.gen(function* () {
const apiKey = yield* openAiKey;
return OpenAiLanguageModel.layer({ model: "gpt-4o-mini" }).pipe(
Layer.provide(OpenAiClient.layer({ apiKey })),
Layer.provide(FetchHttpClient.layer),
);
}),
);
return {
fetch: Effect.gen(function* () {
const response = yield* LanguageModel.generateText({
prompt: "Say hello.",
}).pipe(Effect.orDie);
return yield* HttpServerResponse.json({ text: response.text });
}).pipe(Effect.provide(languageModel)),
};
}),
);

Alchemy.Secret("OPENAI_API_KEY") on Lambda attaches the value as an environment variable on the function. Everything downstream of the Init phase is platform-independent — the same languageModel layer would work on a Container or any other Effect platform.

For multi-turn conversations, Effect’s Chat service holds the turn history and exposes the same generateText / streamText surface. Chat.layerPersisted wraps it with a Chat.Persistence interface that needs a BackingPersistence Layer underneath:

import { Chat } from "effect/unstable/ai";
import * as Persistence from "effect/unstable/persistence/Persistence";
// inside your handler:
const chat = yield* (yield* Chat.Persistence).getOrCreate("session-id");
yield* chat.generateText({ prompt: "Hello again." });

The BackingPersistence Layer is what makes the chat history durable. Pick the one that matches your platform:

PlatformBacking
Cloudflare Worker + Durable ObjectCloudflare.DurableObjectChatPersistence — bytes live in state.storage, one DO instance per session
Any platform, in-memory (tests)Persistence.layerBackingMemory — lost on restart
Anything else (KV, R2, DynamoDB, …)Implement BackingPersistence against your store

The Chat.Persistence API and handler code never change — only the backing layer does. See the Add a Chat Agent tutorial for the end-to-end DO setup.

For Workers AI on Cloudflare, Cloudflare.AiGateway declares an AI Gateway as a resource and .bind(Gateway).model({...}) returns a LanguageModel Layer that proxies Workers AI through the gateway — with caching, rate limiting, retries, and a unified request log.

The AI Gateway tutorial walks through it end to end. Once you have that languageModel Layer it slots into the same Effect.provide(...) spot as any other provider — the rest of your handler doesn’t change.

You can also route OpenAI / Anthropic through the gateway for the same caching and observability:

const aiGateway = yield* Cloudflare.AiGateway.bind(Gateway);
const openAiKey = yield* Alchemy.Secret("OPENAI_API_KEY");
const languageModel = Layer.unwrap(
Effect.gen(function* () {
const apiKey = yield* openAiKey;
const apiUrl = yield* aiGateway.getUrl().pipe(Effect.orDie);
return OpenAiLanguageModel.layer({ model: "gpt-4o-mini" }).pipe(
Layer.provide(OpenAiClient.layer({ apiKey, apiUrl: `${apiUrl}/openai` })),
Layer.provide(FetchHttpClient.layer),
);
}),
);

The model is still GPT, but every request shows up in the AI Gateway dashboard alongside your other model traffic.

Each upstream provider is its own @effect/ai-* package. The shape — *Client.layer({ apiKey }) + *LanguageModel.layer({ model }) — is the same.

  • Effect AI documentation — the API surface (generateText, streamText, Toolkit, structured outputs, embeddings).
  • Add an AI Gateway — wire Workers AI behind a Cloudflare AI Gateway with caching and streaming.
  • Add a Chat Agent — persisted chat sessions backed by a Durable Object’s state.storage.
  • Secrets and env vars — how Alchemy.Secret feeds the upstream providers’ API keys at deploy and runtime.