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.
The pattern
Section titled “The pattern”Every Effect AI provider is two stacked layers:
- A client Layer that holds the API key and HTTP transport
(
OpenAiClient.layer({ apiKey }),AnthropicClient.layer({ apiKey }), …). - 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.
Read the API key with Alchemy.Secret
Section titled “Read the API key with Alchemy.Secret”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 accessorconst 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), ); }),);In a Cloudflare Worker
Section titled “In a Cloudflare Worker”FetchHttpClient.layer plugs the runtime fetch API into the
Effect HTTP client that every provider’s client Layer depends on:
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)), }; }),);In an AWS Lambda Function
Section titled “In an AWS Lambda Function”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:
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.
Persisting chats
Section titled “Persisting chats”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:
| Platform | Backing |
|---|---|
| Cloudflare Worker + Durable Object | Cloudflare.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.
Cloudflare AI Gateway
Section titled “Cloudflare AI Gateway”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.
Picking a provider
Section titled “Picking a provider”Each upstream provider is its own @effect/ai-* package. The
shape — *Client.layer({ apiKey }) + *LanguageModel.layer({ model }) — is the same.
@effect/ai-openai@effect/ai-anthropic@effect/ai-google@effect/ai-amazon-bedrockCloudflare.AiGateway(Workers AI, via Alchemy itself)
Where to go next
Section titled “Where to go next”- 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.Secretfeeds the upstream providers’ API keys at deploy and runtime.