Skip to content

Build a Claude Managed Agent on Vercel

Learn how to build a Claude Managed Agent on Vercel with auth, credential vaults, durable polling, and a chat UI.

12 min read
Last updated April 11, 2026

Claude Managed Agents gives you a fully managed agent runtime. Claude can read files, run commands, browse the web, and call MCP servers without you building the agent loop yourself.

This guide builds an internal knowledge agent on Vercel where your team can ask questions across GitHub, Notion, and Slack. It covers Sign in with Vercel for auth, a durable Workflow that polls and streams agent events in real-time via SSE, and per-user credential vaults so the agent can call MCP servers on behalf of each user.

2026-04-09 at 19.21.21@2x.png

You can try the live demo, browse the source, or continue following along below to understand how everything works from the ground up.

Before diving into code, here's the full data flow:

  1. User opens a new chat. The client calls the session API, which creates an Anthropic session and starts a long-lived Workflow run. The user's first message is passed directly to the workflow as input.
  2. Workflow sends the message. A "use step" function forwards the text to Anthropic via sessions.events.send.
  3. Workflow polls and streams. Another step fetches new events from the Anthropic events API and writes them to the workflow's durable stream via getWritable(). It sleeps 3 seconds between polls, releasing the function instance each time.
  4. Client receives events. An EventSource connection to /api/readable/${runId} delivers events in real-time as they're written.
  5. Turn completes. When a terminal event arrives (like session.status_idle), polling stops and the workflow pauses at a defineHook, waiting for the next message.
  6. User sends a follow-up. The message API calls messageHook.resume() to wake the workflow. Same run, same durable stream.
  7. Page refresh. getReadable() replays all events from the beginning, so the client gets full history without a database query.

The workflow run is both the execution engine and the event log. The only database table is managed_agent_session for metadata like title and user ownership.

  • A Vercel account
  • An Anthropic account with access to Managed Agents (beta)
  • Node.js 20+ and pnpm
pnpm create next-app@latest my-agent --typescript
cd my-agent
vercel link

Install Neon Postgres from the Vercel Marketplace, then pull the provisioned DATABASE_URL into your local .env.local:

vercel integration add neon
vercel env pull
pnpm add @anthropic-ai/sdk better-auth drizzle-orm \
@neondatabase/serverless workflow next-themes streamdown
pnpm add -D drizzle-kit @workflow/next dotenv

Wrap your Next.js config with the Workflow DevKit so the "use workflow" and "use step" directives work:

next.config.ts
import type { NextConfig } from "next";
import { withWorkflow } from "workflow/next";
const nextConfig: NextConfig = {};
export default withWorkflow(nextConfig);

After vercel env pull, your .env.local already has DATABASE_URL. Add the remaining variables:

VariableWhere to get it
ANTHROPIC_API_KEYplatform.claude.com/settings/keys
ANTHROPIC_AGENT_IDAnthropic console, after creating a Managed Agent
ANTHROPIC_ENVIRONMENT_IDSame place as above
VERCEL_CLIENT_IDSign in with Vercel OAuth app
VERCEL_CLIENT_SECRETSame place as above
TOKEN_ENCRYPTION_KEYopenssl rand -hex 32
BETTER_AUTH_SECRETopenssl rand -base64 32

One table for session metadata. Agent events live in the workflow run, not the database:

lib/schema.ts
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
export const managedAgentSession = pgTable(
"managed_agent_session",
{
id: text("id").primaryKey(),
userId: text("user_id").notNull(),
anthropicSessionId: text("anthropic_session_id").notNull().unique(),
title: text("title").notNull().default("New chat"),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
agentId: text("agent_id").notNull(),
environmentId: text("environment_id").notNull(),
workflowRunId: text("workflow_run_id"),
},
);
pnpm drizzle-kit push

A thin wrapper around the SDK:

lib/anthropic.ts
import Anthropic from "@anthropic-ai/sdk";
export function getAnthropic(): Anthropic {
const key = process.env.ANTHROPIC_API_KEY;
if (!key) throw new Error("ANTHROPIC_API_KEY is not set");
return new Anthropic({ apiKey: key });
}

And the session creation helper. It accepts an array of vault IDs so the agent can access per-user MCP credentials (covered in Credential vaults & MCP):

lib/managed-agents.ts
import { getAnthropic } from "./anthropic";
export async function createSession(vaultIds: string[]) {
const client = getAnthropic();
const agentId = process.env.ANTHROPIC_AGENT_ID!;
const environmentId = process.env.ANTHROPIC_ENVIRONMENT_ID!;
const session = await client.beta.sessions.create({
agent: agentId,
environment_id: environmentId,
vault_ids: vaultIds,
});
return {
anthropicSessionId: session.id,
agentId: session.agent.id,
environmentId: session.environment_id,
};
}

This is the core of the integration. Managed Agents are asynchronous: after you send a message, the agent processes it and emits events over time. A durable Workflow polls the events API and streams results to the client. No database writes needed.

Three primitives from the workflow package make this work:

  • defineHook pauses the workflow until resumed from outside (used for receiving follow-up messages)
  • sleep releases the function instance between polls instead of burning compute with setTimeout
  • getWritable writes events to a durable stream that clients read via SSE
app/workflows/tail-session.ts
import { defineHook, sleep, getWritable } from "workflow";
import { getAnthropic } from "@/lib/anthropic";
export type SessionEvent = {
id: string;
type: string;
payload: Record<string, unknown>;
occurredAt: string;
};
export const messageHook = defineHook<{ text: string }>();

The messageHook is exported so the message API route can call messageHook.resume() to wake up the workflow.

A "use step" function forwards the user's text to Anthropic:

async function sendMessage(
anthropicSessionId: string,
text: string,
): Promise<void> {
"use step";
const client = getAnthropic();
await client.beta.sessions.events.send(anthropicSessionId, {
events: [{ type: "user.message", content: [{ type: "text", text }] }],
});
}

Another step fetches new events and writes them to the workflow's durable stream:

async function pollAndStream(input: {
anthropicSessionId: string;
lastEventId: string | null;
}): Promise<{ lastEventId: string | null; done: boolean }> {
"use step";
const client = getAnthropic();
const writer = getWritable<SessionEvent>().getWriter();
let done = false;
let lastId = input.lastEventId;
try {
const page = await client.beta.sessions.events.list(
input.anthropicSessionId,
{ limit: 100 },
);
let seenLast = input.lastEventId === null;
for (const event of page.data) {
if (!seenLast) {
if (event.id === input.lastEventId) seenLast = true;
continue;
}
await writer.write({
id: event.id,
type: event.type,
payload: event as unknown as Record<string, unknown>,
occurredAt: new Date().toISOString(),
});
lastId = event.id;
if (
event.type === "session.status_idle" ||
event.type === "session.status_terminated" ||
event.type === "session.deleted"
) {
done = true;
break;
}
}
} finally {
writer.releaseLock();
}
return { lastEventId: lastId, done };
}

Each writer.write() call durably persists the event and pushes it to any connected SSE clients. The lastEventId tracking ensures only new events are processed across polls.

Ties everything together. The initial message is processed directly, then the workflow enters a hook loop for follow-ups:

const MAX_POLLS_PER_TURN = 200;
async function processTurn(
anthropicSessionId: string,
text: string,
lastEventId: string | null,
): Promise<string | null> {
await sendMessage(anthropicSessionId, text);
let currentLastEventId = lastEventId;
for (let i = 0; i < MAX_POLLS_PER_TURN; i++) {
await sleep("3s");
const result = await pollAndStream({
anthropicSessionId,
lastEventId: currentLastEventId,
});
currentLastEventId = result.lastEventId;
if (result.done) break;
}
return currentLastEventId;
}
export async function sessionWorkflow(input: {
internalSessionId: string;
anthropicSessionId: string;
initialMessage: string;
}) {
"use workflow";
let lastEventId: string | null = null;
// Process the first message directly, no hook needed
lastEventId = await processTurn(
input.anthropicSessionId,
input.initialMessage,
lastEventId,
);
// Wait for follow-up messages via the hook
const hook = messageHook.create({
token: `msg:${input.internalSessionId}`,
});
for await (const { text } of hook) {
lastEventId = await processTurn(
input.anthropicSessionId,
text,
lastEventId,
);
}
}

Why is the first message passed as initialMessage instead of going through the hook? On Vercel, start() returns the run ID before the workflow has executed to the point where messageHook.create() runs. If the client immediately called resume(), the hook wouldn't exist yet. Baking the first message into the workflow input sidesteps this race condition entirely.

The for await (const { text } of hook) pattern receives each resume() call as a new iteration. The hook is created once with a deterministic token, and the workflow pauses between turns. When messageHook.resume("msg:${sessionId}", { text }) is called, the workflow wakes up, processes the turn, then pauses again.

sleep() between polls releases the function instance. If the function crashes, the Workflow SDK replays from the last completed step. The lastEventId carries across turns, so follow-up messages only fetch new events.

The client reads events from the workflow through a Server-Sent Events endpoint:

// app/api/readable/[runId]/route.ts
import { NextRequest } from "next/server";
import { eq, and } from "drizzle-orm";
import { getRun } from "workflow/api";
import { requireUserId } from "@/lib/session";
import { db } from "@/lib/db";
import { managedAgentSession } from "@/lib/schema";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ runId: string }> },
) {
const authz = await requireUserId();
if ("error" in authz) return authz.error;
const { runId } = await params;
// Verify the user owns this session
const [row] = await db
.select({ id: managedAgentSession.id })
.from(managedAgentSession)
.where(
and(
eq(managedAgentSession.workflowRunId, runId),
eq(managedAgentSession.userId, authz.userId),
),
)
.limit(1);
if (!row) {
return Response.json({ error: "Not found" }, { status: 404 });
}
const run = getRun(runId);
const readable = run.getReadable();
const encoder = new TextEncoder();
const sseStream = new ReadableStream({
async start(controller) {
const reader = (readable as unknown as ReadableStream).getReader();
try {
while (!request.signal.aborted) {
const { done, value } = await reader.read();
if (done) break;
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(value)}\n\n`),
);
}
controller.close();
} finally {
reader.releaseLock();
}
},
});
return new Response(sseStream, {
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
},
});
}

getRun(runId).getReadable() replays all events from the beginning, then keeps the connection open for new ones. On page refresh, the client reconnects and gets full history automatically.

The session route creates an Anthropic session, starts the workflow with the user's first message, and returns the run ID so the client can connect to the event stream:

app/api/managed-agents/session/route.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { start } from "workflow/api";
import { db } from "@/lib/db";
import { managedAgentSession } from "@/lib/schema";
import { createSession } from "@/lib/managed-agents";
import { requireUserId } from "@/lib/session";
import { getOrCreateVaultForUser, syncMCPCredential } from "@/lib/vault";
import { getUserToken, MCP_SERVERS } from "@/lib/mcp-oauth";
import { sessionWorkflow } from "@/app/workflows/tail-session";
export async function POST(request: NextRequest) {
const authz = await requireUserId();
if ("error" in authz) return authz.error;
const { text } = await request.json();
if (!text?.trim()) {
return NextResponse.json({ error: "text is required" }, { status: 400 });
}
const id = crypto.randomUUID();
const title = text.length > 60 ? `${text.slice(0, 57)}...` : text;
const vaultId = await getOrCreateVaultForUser(authz.userId);
// Sync MCP tokens into the vault
await Promise.all(
Object.entries(MCP_SERVERS).map(async ([name, info]) => {
const token = await getUserToken(authz.userId, name);
if (token) await syncMCPCredential(vaultId, name, info.url, token);
}),
);
const anthropic = await createSession([vaultId]);
const run = await start(sessionWorkflow, [
{
internalSessionId: id,
anthropicSessionId: anthropic.anthropicSessionId,
initialMessage: text.trim(),
},
]);
await db.insert(managedAgentSession).values({
id,
userId: authz.userId,
anthropicSessionId: anthropic.anthropicSessionId,
title,
agentId: anthropic.agentId,
environmentId: anthropic.environmentId,
workflowRunId: run.runId,
});
return NextResponse.json({ id, runId: run.runId });
}

The first message is part of the session creation request. The workflow starts processing it immediately, no separate message send needed.

For subsequent messages, the route resumes the workflow's hook:

app/api/managed-agents/message/route.ts
import { and, eq } from "drizzle-orm";
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { managedAgentSession } from "@/lib/schema";
import { requireUserId } from "@/lib/session";
import { messageHook } from "@/app/workflows/tail-session";
export async function POST(request: Request) {
const authz = await requireUserId();
if ("error" in authz) return authz.error;
const { sessionId, text } = await request.json();
const [row] = await db
.select()
.from(managedAgentSession)
.where(
and(
eq(managedAgentSession.id, sessionId),
eq(managedAgentSession.userId, authz.userId),
),
)
.limit(1);
if (!row) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
await db
.update(managedAgentSession)
.set({ updatedAt: new Date() })
.where(eq(managedAgentSession.id, sessionId));
await messageHook.resume(`msg:${sessionId}`, { text });
return NextResponse.json({ ok: true });
}

No start() call here. The workflow was already started when the session was created. messageHook.resume() wakes it up with the new text, using the deterministic token msg:${sessionId} to find the right hook.

The chat panel connects to the SSE stream on mount and receives events in real-time:

// components/chat/chat-panel.tsx (simplified)
"use client";
import { useEffect, useRef, useState } from "react";
type TranscriptEvent = {
id: string;
type: string;
payload: Record<string, unknown>;
occurredAt: string;
};
export function ChatPanel({ sessionId }: { sessionId: string }) {
const [events, setEvents] = useState<TranscriptEvent[]>([]);
const [tailing, setTailing] = useState(false);
const eventSourceRef = useRef<EventSource | null>(null);
const seenIdsRef = useRef(new Set<string>());
const runIdRef = useRef<string | null>(null);
function connectToStream(runId: string) {
eventSourceRef.current?.close();
runIdRef.current = runId;
const es = new EventSource(`/api/readable/${runId}`);
eventSourceRef.current = es;
setTailing(true);
es.onmessage = (msg) => {
const ev = JSON.parse(msg.data);
if (seenIdsRef.current.has(ev.id)) return;
seenIdsRef.current.add(ev.id);
setEvents((prev) => {
if (prev.some((e) => e.id === ev.id)) return prev;
return [...prev, ev];
});
};
es.onerror = () => {
es.close();
eventSourceRef.current = null;
};
}
// On mount, fetch the runId and connect
useEffect(() => {
let cancelled = false;
async function init() {
const res = await fetch(
`/api/managed-agents/transcript?sessionId=${sessionId}`,
);
const data = await res.json();
if (cancelled) return;
if (data.workflowRunId) {
connectToStream(data.workflowRunId);
}
}
void init();
return () => {
cancelled = true;
eventSourceRef.current?.close();
};
}, [sessionId]);
async function handleSend(text: string) {
// Optimistic update
setEvents((prev) => [
...prev,
{
id: `optimistic-${Date.now()}`,
type: "user.message",
payload: { content: [{ type: "text", text }] },
occurredAt: new Date().toISOString(),
},
]);
await fetch("/api/managed-agents/message", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId, text }),
});
// Reconnect: the stream closes between turns
// when the workflow pauses at the hook
if (runIdRef.current) {
connectToStream(runIdRef.current);
}
}
return (
<div>
{events.map((ev) => (
<EventRenderer key={ev.id} event={ev} />
))}
{tailing && <div>Thinking...</div>}
</div>
);
}

The EventSource closes when the workflow pauses between turns (the readable stream ends). On follow-ups, handleSend reconnects it. The seenIdsRef deduplicates events that get replayed on reconnection. The optimistic user message appears immediately and is replaced when the real event arrives.

Vaults let you register per-user credentials and reference them by ID at session creation. Each user gets a vault, and you sync MCP tokens into it so the agent can authenticate with external services.

lib/vault.ts
import { getAnthropic } from "./anthropic";
import { db } from "./db";
import { user } from "./schema";
export async function getOrCreateVaultForUser(userId: string): Promise<string> {
const [row] = await db
.select({ vaultId: user.vaultId })
.from(user)
.where(eq(user.id, userId))
.limit(1);
if (row?.vaultId) return row.vaultId;
const client = getAnthropic();
const vault = await client.beta.vaults.create({
display_name: `user-${userId}`,
metadata: { userId },
});
await db.update(user).set({ vaultId: vault.id }).where(eq(user.id, userId));
return vault.id;
}
export async function syncMCPCredential(
vaultId: string,
serverName: string,
serverUrl: string,
token: string,
): Promise<string> {
const client = getAnthropic();
const credential = await client.beta.vaults.credentials.create(vaultId, {
display_name: `${serverName} Token`,
auth: {
type: "static_bearer",
token,
mcp_server_url: serverUrl,
},
});
return credential.id;
}

The static_bearer type tells Anthropic to inject this token as a Bearer header when the agent connects to the specified MCP server URL. Your application never passes tokens directly to the agent.

Managed Agents supports MCP servers as first-class tools. The pattern for each integration:

  1. User completes OAuth with the MCP provider (GitHub, Notion, Slack)
  2. Store the encrypted access token in your database
  3. On session creation, sync the token into the user's vault
  4. The agent authenticates automatically when calling that MCP server

You configure which MCP servers the agent has access to in the Anthropic console as part of the agent's tool configuration.

Deploy the template in one click, or give this guide (or the project's SPEC.md) directly to your coding agent.

  • Add more MCP servers: Connect Linear, Jira, or custom internal tools by registering their MCP URLs in the agent config and implementing the OAuth flow
  • Stream responses: The Anthropic API supports SSE streaming for even lower latency than polling the events list endpoint
  • Custom tools: Define custom tools to extend the agent with your own business logic
  • Multi-agent: Use multi-agent orchestration to coordinate specialized agents

For more on the Managed Agents API, see the official documentation.

Was this helpful?

supported.

Read related documentation

No related documentation available.

Explore more guides

No related guides available.