Skip to content
zap.ts
Esc
navigateopen⌘Jpreview
On this page

AI

One way to call a model, for text, objects, embeddings, images, video, speech and transcription.

@zap-ts/ai gives the app one way to call an AI model. It covers text, structured output, summaries, embeddings, images, video, speech and transcription. Each one is a function. Each function retries itself when the model provider fails. The app imports @zap-ts/ai, and nothing else. There is only one export path.

The package is built on @tanstack/ai. The model provider is OpenAI, through @tanstack/ai-openai.

Every function returns an Effect. An Effect is a description of work. It does not run until you run it.

The AI service

The package does not hold a client of its own. Instead it has two pieces:

  1. AI is a tag. A tag is a name that stands for a service you will provide later. This tag holds one adapter per capability. An adapter is the small piece of code that talks to OpenAI.
  2. AILive is the layer that builds those adapters. A layer is the recipe that makes a service real.

AILive reads OPENAI_API_KEY with requireEnv. So a missing key fails at once, when you build the layer. It does not fail later, in the middle of a request.

import { AILive, generateText } from "@zap-ts/ai";
import { Effect } from "effect";

const answer = await Effect.runPromise(
  generateText({ messages: [{ role: "user", content: "Hi!" }] }).pipe(Effect.provide(AILive)),
);

In apps/web the layer is built once, in src/lib/ai.ts. Every call shares it. Your code calls runAI and never names the layer:

import { generateText } from "@zap-ts/ai";

import { runAI } from "../lib/ai";

const answer = await runAI(generateText({ messages: [{ role: "user", content: "Hi!" }] }));

Wait, or stream

Each capability comes in two shapes.

The first shape waits. It gives you the answer when the model has finished: generateText, generateObject, summarizeText, generateEmbeddings, createImage, createVideo, createSpeech and transcribe. A video can take a long time, so getVideoStatus reads the state of a video job that still runs.

The second shape streams. It gives you the answer in small pieces, as the model writes it: streamText, streamObject, streamSummarizeText, streamImage, streamVideo, streamSpeech and streamTranscription.

import { AILive, streamText } from "@zap-ts/ai";
import { Effect, Stream } from "effect";

const program = streamText({
  messages: [{ role: "user", content: "Write a poem." }],
}).pipe(Stream.runForEach((chunk) => Effect.log(chunk)));

await Effect.runPromise(program.pipe(Effect.provide(AILive)));

Do you want to send the pieces straight to a browser? Then use streamTextResponse. It gives you an HTTP response instead of a stream.

Structured output

Sometimes you do not want free text. You want an object with fields you can trust. Give generateObject an outputSchema. A schema says which fields the object has, and what type each field is. You get back the parsed object, and TypeScript knows its shape:

const user = await runAI(
  generateObject({
    messages: [{ role: "user", content: "Give me a fake user." }],
    outputSchema: z.object({ name: z.string(), age: z.number() }),
  }),
);

streamObject is the same call. It builds the object piece by piece.

Tools

A tool is a function you let the model call. You declare one with toolDefinition, which the package re-exports from @tanstack/ai.

Is the work of your tool written with Effect? Then use effectTool. You write the body as an Effect, and the package runs it for you:

const getTime = effectTool(
  {
    name: "getTime",
    description: "Gives the current time.",
    inputSchema: z.object({}),
    outputSchema: z.object({ now: z.string() }),
  },
  () => Effect.succeed({ now: new Date().toISOString() }),
);

Retries and errors

Every call retries in the same way. It waits 200ms, then doubles the wait each time, and it tries three times in total. The wait also gets a small random change, so many calls do not all retry at the same moment.

A retry writes a debug log. Three failed tries write a warning log.

Then you get an AIError. It carries the original error in its cause field. So your code handles one error type, whatever the provider did.

Models

The model names live in one place, packages/ai/src/index.ts:

export const DEFAULT_MODELS = {
  embedding: "text-embedding-3-large",
  image: "gpt-image-2",
  speech: "tts-1-hd",
  text: "gpt-5.2",
  transcription: "gpt-4o-transcribe",
  video: "sora-2",
} as const;

The text model is used for chat and for summaries. Change a value here, and every call that uses that capability changes too.

Changing the provider

Only OpenAI is set up. AILive builds its seven adapters with the createOpenai* functions of @tanstack/ai-openai. An adapter is one capability from one company, so a swap is one import, one type and one set of model names.

@tanstack/ai has an adapter package per company: @tanstack/ai-anthropic, @tanstack/ai-gemini, @tanstack/ai-mistral, @tanstack/ai-grok, @tanstack/ai-groq, @tanstack/ai-cohere, @tanstack/ai-ollama, @tanstack/ai-bedrock, @tanstack/ai-openrouter, @tanstack/ai-perplexity, @tanstack/ai-elevenlabs, @tanstack/ai-fal, and more.

The five steps

  1. Add the adapter package. Put the version in catalog: in pnpm-workspace.yaml, write "@tanstack/ai-gemini": "catalog:" in packages/ai/package.json, then pnpm install. Never a version number in a package.json.

  2. Declare the new key in the root .env.schema, next to OPENAI_API_KEY, with its @docs link and its @type. Put the real key in .env.local.

  3. In packages/ai/src/index.ts, change the imports, the AIAdapters type and the AILive layer together. They name the same functions three times:

    import {
      createGeminiChat,
      createGeminiEmbedding,
      createGeminiImage,
      createGeminiSpeech,
      createGeminiSummarize,
      createGeminiVideo,
    } from "@tanstack/ai-gemini";
    
    export type AIAdapters = {
      embedding: ReturnType<typeof createGeminiEmbedding>;
      // ...one line per capability
    };
    
    export const AILive: Layer.Layer<AI> = Layer.sync(AI, () => {
      const apiKey = requireEnv("GEMINI_API_KEY");
    
      return {
        embedding: createGeminiEmbedding(DEFAULT_MODELS.embedding, apiKey),
        // ...one line per capability
      };
    });
  4. In the same file, change DEFAULT_MODELS to the names that company uses. A model name from one company means nothing to another one.

  5. Run the gate: pnpm run env:web && pnpm run typecheck && pnpm run test.

Nothing else changes. generateText and the other functions only ever see an adapter. They never see the company behind it.

Not every company does everything

OpenAI is the one company that covers all seven capabilities. Most cover a few:

Package Covers
@tanstack/ai-openai all seven
@tanstack/ai-gemini text, summarize, embedding, image, video, speech, audio
@tanstack/ai-anthropic text, summarize
@tanstack/ai-mistral text, embedding
@tanstack/ai-elevenlabs speech, transcription
@tanstack/ai-fal video

Check the factory names the package exports before you start. They follow one shape, create<Company><Capability>.

Mixing companies

The seven fields of AIAdapters are independent, so one field per company is fine. That is how you use a company that has no image model, or a voice company for speech only:

export const AILive: Layer.Layer<AI> = Layer.sync(AI, () => ({
  text: createAnthropicChat(DEFAULT_MODELS.text, requireEnv("ANTHROPIC_API_KEY")),
  summarize: createAnthropicSummarize(DEFAULT_MODELS.text, requireEnv("ANTHROPIC_API_KEY")),
  speech: createElevenLabsSpeech(DEFAULT_MODELS.speech, requireEnv("ELEVENLABS_API_KEY")),
  // ...the rest stay on OpenAI
}));

Every key you read with requireEnv must exist when the layer is built. So declare each one in .env.schema, and keep the OpenAI key as long as one adapter still comes from OpenAI.

A capability nobody in your app calls still needs an adapter, because AIAdapters has all seven fields. Leave that one on OpenAI, or drop the field from the type and from the layer, and the functions that use it stop typechecking.

Environment

OPENAI_API_KEY=

The root .env.schema file declares this variable. It must be a string that starts with sk-, and the file links to the OpenAI page where you get one.

The variable is optional there, but AILive needs it. That is why the layer reads it with requireEnv. So the app starts without an OpenAI key, and only an AI call complains.

See Environment to learn how varlock turns those declarations into the typed ENV that the code imports.

  • Environmentenv and requireEnv
  • Web — where the shared AI runtime lives

Last updated on September 22, 2026

Was this page helpful?