# Build a Claude Managed Agent with Vercel Workflow

**Author:** Allen Zhou

---

[Claude Managed Agents](https://platform.claude.com/docs/en/managed-agents/overview) 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](https://vercel.com) where your team can ask questions across GitHub, Notion, and Slack. It covers [Sign in with Vercel](https://vercel.com/docs/sign-in-with-vercel/getting-started) for auth, a durable [Workflow](https://vercel.com/docs/workflow) that polls and streams agent events in real-time via SSE, and per-user [credential vaults](https://platform.claude.com/docs/en/managed-agents/vaults) so the agent can call MCP servers on behalf of each user.

You can try the [live demo](https://claude-managed-agents.labs.vercel.dev/), browse the [source](https://github.com/vercel-labs/claude-managed-agents-starter), or continue following along below to understand how everything works from the ground up.

## How it works

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](https://vercel.com/docs/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.

## Setup

### Prerequisites

- A [Vercel](https://vercel.com) account
  
- An [Anthropic](https://console.anthropic.com) account
  
- Node.js 20+ and pnpm
  

### Create the project

`pnpm create next-app@latest my-agent --typescript cd my-agent vercel link`

### Add a database

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`

### Install dependencies

`pnpm add @anthropic-ai/sdk better-auth drizzle-orm \ @neondatabase/serverless workflow next-themes streamdown pnpm add -D drizzle-kit @workflow/next dotenv`

### Configure Next.js

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

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

### Environment variables

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

| Variable                   | Where to get it                                                                              |
| -------------------------- | -------------------------------------------------------------------------------------------- |
| `ANTHROPIC_API_KEY`        | [platform.claude.com/settings/keys](https://platform.claude.com/settings/keys)               |
| `ANTHROPIC_AGENT_ID`       | Anthropic console, after creating a Managed Agent                                            |
| `ANTHROPIC_ENVIRONMENT_ID` | Same place as above                                                                          |
| `VERCEL_CLIENT_ID`         | [Sign in with Vercel](https://vercel.com/docs/sign-in-with-vercel/getting-started) OAuth app |
| `VERCEL_CLIENT_SECRET`     | Same place as above                                                                          |
| `TOKEN_ENCRYPTION_KEY`     | `openssl rand -hex 32`                                                                       |
| `BETTER_AUTH_SECRET`       | `openssl rand -base64 32`                                                                    |

### Database schema

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

`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`

## Connect to Anthropic

A thin wrapper around the SDK:

`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](https://platform.claude.com/docs/en/managed-agents/vaults) IDs so the agent can access per-user MCP credentials (covered in [Credential vaults & MCP](#credential-vaults--mcp)):

`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, }; }` ## The durable workflow 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](https://vercel.com/docs/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
  

### Hook and event types

`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.

### Send a message

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 }] }], }); }` ### Poll and stream events 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. ### The workflow function 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. ## Serve events via SSE 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. ## API routes ### Create a session 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: ``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. ### Send a follow-up message For subsequent messages, the route resumes the workflow's hook: ``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. ## Build the chat UI 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. ## Credential vaults & MCP [Vaults](https://platform.claude.com/docs/en/managed-agents/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.

``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. ### Adding MCP integrations [Managed Agents supports MCP servers](https://platform.claude.com/docs/en/managed-agents/tools) 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.

## Next steps

[Deploy the template](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel-labs%2Fclaude-managed-agents-starter&project-name=claude-managed-agent&env=ANTHROPIC_API_KEY,ANTHROPIC_AGENT_ID,ANTHROPIC_ENVIRONMENT_ID,BETTER_AUTH_SECRET,VERCEL_CLIENT_ID,VERCEL_CLIENT_SECRET,TOKEN_ENCRYPTION_KEY&products=%5B%7B%22type%22%3A%22integration%22%2C%22protocol%22%3A%22storage%22%2C%22productSlug%22%3A%22neon%22%2C%22integrationSlug%22%3A%22neon%22%7D%5D) in one click, or give this guide (or the project's [SPEC.md](https://github.com/vercel-labs/claude-managed-agents-starter/blob/main/docs/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
  
- **Custom tools**: Define [custom tools](https://platform.claude.com/docs/en/managed-agents/tools#custom-tools) to extend the agent with your own business logic
  
- **Multi-agent**: Use [multi-agent orchestration](https://platform.claude.com/docs/en/managed-agents/multi-agent) to coordinate specialized agents
  

For more on the Managed Agents API, see the [official documentation](https://platform.claude.com/docs/en/managed-agents/overview).

---

[View full KB sitemap](/kb/sitemap.md)
