Skip to content

feat(vercel): add AI SDK adapter#290

Draft
robelest wants to merge 3 commits into
get-convex:mainfrom
robelest:robel/agent-vercel-adapter
Draft

feat(vercel): add AI SDK adapter#290
robelest wants to merge 3 commits into
get-convex:mainfrom
robelest:robel/agent-vercel-adapter

Conversation

@robelest

@robelest robelest commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds a Vercel AI SDK 7 adapter on top of Agent V2.

This PR is stacked on #289. Agent V2 remains the durable Convex primitive: it owns threads, messages, runs, tools, approvals, usage, structured output, cancellation, and run event streams. The Vercel adapter lets apps use AI SDK model providers and AI SDK React without moving UIMessage, ModelMessage, or provider-specific APIs into Agent core.

Stack

Base PR: #289, refactor(agent): introduce durable run core

This PR adds the Vercel layer and updates the example to prove the happy path.

Public API

Server-side model adapter:

import { openai } from "@ai-sdk/openai";
import { Agent } from "@convex-dev/agent";
import { defineModel } from "@convex-dev/agent/vercel";
import { components } from "./_generated/api";

export const supportAgent = new Agent(components.agent, {
  name: "Support Agent",
  model: defineModel({
    model: openai("gpt-4.1-mini"),
    temperature: 0.2,
  }),
});

React chat transport over Convex realtime:

import { useChat } from "@ai-sdk/react";
import { useChatTransport } from "@convex-dev/agent/vercel/react";
import { api } from "../convex/_generated/api";

function SupportCase({ caseId }: { caseId: string }) {
  const chat = useChat(
    useChatTransport(
      {
        list: api.support.chat.list,
        send: api.support.chat.send,
        read: api.support.chat.read,
        resume: api.support.chat.resume,
        cancel: api.support.chat.cancel,
      },
      { caseId },
      { cancelOnAbort: true },
    ),
  );

  return (
    <form
      onSubmit={(event) => {
        event.preventDefault();
        void chat.sendMessage({ text: "Help me with this case." });
      }}
    >
      {chat.messages.map((message) => (
        <Message key={message.id} message={message} />
      ))}
    </form>
  );
}

The app passes normal Convex function references. The adapter handles UIMessage conversion, run-event streaming, reconnection, and cancel propagation.

Tool Composition

Agent tools stay Agent-native. The Vercel adapter maps their input schemas into AI SDK streamText, but Agent still owns approval, execution, output validation, persistence, and replay.

import { defineTool } from "@convex-dev/agent";
import { v } from "convex/values";

const refundPayment = defineTool({
  description: "Refund a customer payment.",
  input: v.object({
    paymentId: v.string(),
    amount: v.number(),
  }),
  output: v.object({
    refunded: v.string(),
    amount: v.number(),
  }),
  needsApproval: (input) => input.amount > 100,
  execute: async (input, context) => {
    if (context.signal?.aborted) {
      throw new Error("Canceled");
    }

    return {
      refunded: input.paymentId,
      amount: input.amount,
    };
  },
});

export const supportAgent = new Agent(components.agent, {
  name: "Support Agent",
  model: defineModel({
    model: openai("gpt-4.1-mini"),
  }),
  tools: {
    refundPayment,
  },
});

The execution path is still Convex-native:

  1. AI SDK provider receives a model-facing tool schema.
  2. The model emits a tool call.
  3. Agent validates input with the Convex validator.
  4. Agent persists tool.call and projects tool state into bounded rows.
  5. Agent enforces needsApproval.
  6. Agent executes the app-owned tool after approval, if required.
  7. Agent validates output with the optional output validator.
  8. Agent persists tool.result, usage, output, and final messages.

This keeps the core tool shape adapter-neutral. A TanStack adapter can map the same defineTool result to TanStack's toolDefinition({ inputSchema, outputSchema, needsApproval }); LangChain, Mastra, or custom provider adapters can do their own translation without changing Agent core.

AI SDK Helpers Stay Composable

This adapter does not wrap every AI SDK helper. That is intentional. Agent should do one thing: durable agent execution. Apps can still use the AI SDK directly for provider-specific work around Agent.

Image generation inside an Agent tool

import { generateImage } from "ai";
import { openai } from "@ai-sdk/openai";

const createReturnLabel = defineTool({
  description: "Create a return-label illustration for the customer.",
  input: v.object({ prompt: v.string() }),
  output: v.object({ fileId: v.string(), mediaType: v.string() }),
  execute: async ({ prompt }, { ctx, signal }) => {
    const { images } = await generateImage({
      model: openai.imageModel("gpt-image-1"),
      prompt,
      size: "1024x1024",
      abortSignal: signal,
    });

    const storageId = await saveImageToAppStorage(ctx, images[0]);
    return { fileId: storageId, mediaType: "image/png" };
  },
});

Agent stores the result as Agent tool output or an Agent file part. The app still owns storage, authorization, and file lifecycle.

Embeddings and RAG as app-owned context

import { embedMany } from "ai";
import { openai } from "@ai-sdk/openai";

export const indexArticle = internalAction({
  args: { articleId: v.id("articles") },
  handler: async (ctx, { articleId }) => {
    const article = await ctx.runQuery(internal.articles.get, { articleId });
    const chunks = chunkArticle(article.body);
    const { embeddings } = await embedMany({
      model: openai.embedding("text-embedding-3-small"),
      values: chunks,
    });

    await ctx.runMutation(internal.articles.storeEmbeddings, {
      articleId,
      chunks,
      embeddings,
    });
  },
});

export const executeRun = internalAction({
  args: { runId: v.string() },
  handler: async (ctx, { runId }) => {
    await supportAgent.runs.execute(ctx, {
      runId,
      context: [loadSupportKnowledge],
    });
  },
});

Agent receives AgentContextBlock[]. It does not own embeddings, vector tables, RAG indexing, or retrieval policy.

Structured generation outside a run

AI SDK 7 routes structured output through generateText / streamText with output. Apps can use that for side tasks and then save the result into app tables, Agent messages, or run-owned output events.

import { Output, generateText } from "ai";
import { z } from "zod";

const { output } = await generateText({
  model: openai("gpt-4.1-mini"),
  prompt: "Classify this support case: refund requested after shipment delay.",
  output: Output.object({
    schema: z.object({
      category: z.enum(["refund", "shipping", "account"]),
      confidence: z.number(),
    }),
  }),
});

await ctx.runMutation(internal.support.classifications.save, {
  runId,
  classification: output,
});

Agent core also supports run-owned structured output through new Agent(..., { output: v.object(...) }) and Agent output events. Apps can choose the shape that fits the workflow.

Provider-specific helpers remain available

Because defineModel accepts AI SDK streamText options, apps keep provider settings where they belong:

const model = defineModel({
  model: openai("gpt-4.1-mini"),
  temperature: 0.2,
  providerOptions: {
    openai: {
      reasoningEffort: "low",
    },
  },
});

Agent does not hide provider configuration behind a new wrapper language.

Convex Realtime Transport

The React adapter uses Convex subscriptions, not the Vercel HTTP transport path.

A server module provides conventional Convex functions:

export const list = query({
  args: { caseId: v.id("cases") },
  returns: v.array(vAgentMessageDoc),
  handler: async (ctx, args) => {
    const supportCase = await requireCaseAccess(ctx, args.caseId);
    const page = await supportAgent.messages.list(ctx, {
      threadId: supportCase.threadId,
      order: "desc",
      paginationOpts: { cursor: null, numItems: 50 },
    });
    return page.page.toReversed();
  },
});

export const send = mutation({
  args: {
    caseId: v.id("cases"),
    chatId: v.string(),
    trigger: v.union(v.literal("submit-message"), v.literal("regenerate-message")),
    messageId: v.optional(v.string()),
    message: vAgentMessageInput,
    messages: v.array(v.any()),
    body: v.optional(v.any()),
    metadata: v.optional(v.any()),
  },
  returns: vPublicRun,
  handler: async (ctx, args) => {
    const supportCase = await requireCaseAccess(ctx, args.caseId);
    const run = await supportAgent.runs.start(ctx, {
      threadId: supportCase.threadId,
      message: args.message,
      key: `client-message:${args.message.clientKey ?? args.messageId ?? args.chatId}`,
    });

    await ctx.scheduler.runAfter(0, internal.support.executeRun, {
      runId: run.runId,
    });

    return run;
  },
});

export const read = query({
  args: {
    caseId: v.id("cases"),
    runId: v.string(),
    streamArgs: streamQueryArgsValidator,
  },
  returns: vRunEventRead,
  handler: async (ctx, args) => {
    await requireRunAccess(ctx, args.caseId, args.runId);
    return await supportAgent.events.read(ctx, {
      runId: args.runId,
      ...args.streamArgs,
    });
  },
});

useChatTransport calls send, then watches read over the normal Convex connection. Stream remains internal to Agent users, but the adapter reuses Agent's Stream-backed run events to emit live AI SDK UIMessageChunk updates.

Public vs Internal

Public exports:

// @convex-dev/agent/vercel
defineModel(...)

// @convex-dev/agent/vercel/react
useChatTransport(...)
createChatTransport(...)

Internal translation code maps between:

  • Agent messages and AI SDK UIMessage;
  • Agent run events and AI SDK UIMessageChunk;
  • Agent tools and AI SDK streamText tool schemas.

The public happy path is intentionally small: define an Agent model with AI SDK provider settings, define Agent tools once, and use AI SDK React with a Convex realtime transport.

@pkg-pr-new

pkg-pr-new Bot commented Jun 30, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/get-convex/agent/@convex-dev/agent@ee3d582

commit: ee3d582

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant