n4nAI

Vercel AI SDK setup with n4n.ai: Claude 3.5 Sonnet example

Step-by-step tutorial for wiring Vercel AI SDK to n4n.ai with Claude 3.5 Sonnet, including streaming, tool calls, and error handling.

n4n Team3 min read712 words

Audio narration

Coming soon — every post will get a voice note here.

The Vercel AI SDK gives you a clean abstraction layer for LLM interactions, but the documentation assumes you’re calling providers directly. When you route through a gateway like n4n.ai, the setup changes slightly: you point the OpenAI-compatible client at a different base URL and pass routing headers. This tutorial walks through a complete, runnable Next.js app that streams Claude 3.5 Sonnet responses, handles tool calls, and degrades gracefully when the gateway returns errors.

Prerequisites

  • Node.js 20+ and pnpm (or npm/yarn)
  • An n4n.ai API key with access to Anthropic models
  • Basic familiarity with Next.js App Router and TypeScript

Create a fresh project if you don’t have one:

pnpm create next-app@latest ai-sdk-demo --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
cd ai-sdk-demo

Install the AI SDK packages and the OpenAI client (which n4n.ai’s endpoint speaks):

pnpm add ai @ai-sdk/openai zod

Environment configuration

Never commit secrets. Create .env.local at the repo root:

# .env.local
N4N_API_KEY="n4n_sk_..."
N4N_BASE_URL="https://api.n4n.ai/v1"

The base URL is the only thing that changes versus a direct Anthropic integration. The SDK’s OpenAI provider works because n4n.ai exposes an OpenAI-compatible /chat/completions endpoint that translates to upstream providers.

Gateway client wrapper

Create a small module that configures the provider once. This keeps route handlers clean and makes it trivial to swap models or add default headers later.

// src/lib/gateway.ts
import { createOpenAI } from '@ai-sdk/openai';

export const gateway = createOpenAI({
  apiKey: process.env.N4N_API_KEY,
  baseURL: process.env.N4N_BASE_URL,
});

export const sonnet = gateway('anthropic/claude-3.5-sonnet');

The model ID anthropic/claude-3.5-sonnet follows n4n.ai’s provider/model convention. If you need a specific version pin, use anthropic/claude-3.5-sonnet-20241022.

Streaming chat route

The App Router route handler streams tokens to the client using streamText. This is the pattern you’ll use for most chat interfaces.

// src/app/api/chat/route.ts
import { streamText } from 'ai';
import { sonnet } from '@/lib/gateway';
import { z } from 'zod';

export const maxDuration = 30;

export async function POST(req: Request) {
  const { messages } = await req.json();

  const result = streamText({
    model: sonnet,
    messages,
    system: 'You are a concise coding assistant. Prefer code over prose.',
    tools: {
      getWeather: {
        parameters: z.object({
          location: z.string().describe('City and state, e.g. "San Francisco, CA"'),
          unit: z.enum(['celsius', 'fahrenheit']).default('fahrenheit'),
        }),
        execute: async ({ location, unit }) => {
          // In production, call a real weather API here
          const temp = unit === 'celsius' ? 18 : 64;
          return { location, temperature: temp, unit, condition: 'Partly cloudy' };
        },
      },
    },
    onError: (error) => {
      console.error('[chat] stream error:', error);
    },
  });

  return result.toDataStreamResponse({
    // Send usage and finish reason in the final chunk
    sendUsage: true,
    sendFinishReason: true,
  });
}

Key points:

  • streamText returns a StreamTextResult that handles backpressure and SSE formatting.
  • The tools object defines functions the model can call. execute runs on your server — keep it fast and idempotent.
  • toDataStreamResponse emits the AI SDK’s wire protocol: text deltas, tool calls, tool results, usage, and finish reason.

Client-side hook

The useChat hook manages message state, streaming, and tool call round-trips automatically.

// src/app/chat.tsx
'use client';

import { useChat } from 'ai/react';
import { useState } from 'react';

export default function Chat() {
  const [isLoading, setIsLoading] = useState(false);
  const { messages, input, handleInputChange, handleSubmit, status, error } = useChat({
    api: '/api/chat',
    onError: (err) => {
      console.error('Chat error:', err);
      setIsLoading(false);
    },
    onFinish: () => setIsLoading(false),
    onResponse: (res) => {
      if (!res.ok) {
        throw new Error(`HTTP ${res.status}: ${res.statusText}`);
      }
    },
  });

  return (
    <div className="max-w-2xl mx-auto p-4 space-y-4">
      <div className="space-y-3">
        {messages.map((m) => (
          <div key={m.id} className={`p-3 rounded-lg ${m.role === 'user' ? 'bg-blue-50' : 'bg-gray-50'}`}>
            <p className="font-mono text-sm text-gray-500">{m.role}</p>
            <p className="whitespace-pre-wrap">{m.content}</p>
            {m.toolInvocations?.length && (
              <details className="mt-2 text-sm">
                <summary className="cursor-pointer text-gray-600">Tool calls</summary>
                <pre className="mt-1 p-2 bg-gray-100 rounded overflow-auto">
                  {JSON.stringify(m.toolInvocations, null, 2)}
                </pre>
              </details>
            )}
          </div>
        ))}
        {status === 'streaming' && <div className="text-sm text-gray-500 animate-pulse">▌</div>}
      </div>

      {error && (
        <div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">
          {error.message}
        </div>
      )}

      <form onSubmit={handleSubmit} className="flex gap-2">
        <input
          value={input}
          onChange={handleInputChange}
          placeholder="Ask about code, weather, anything..."
          className="flex-1 px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
          disabled={status === 'streaming'}
        />
        <button
          type="submit"
          disabled={status === 'streaming' || !input.trim()}
          className="px-4 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
        >
          Send
        </button>
      </form>
    </div>
  );
}

The hook handles the full lifecycle: optimistic UI updates, streaming deltas, tool call execution (the model calls getWeather, your server runs it, the result goes back to the model, and the final answer streams in), and error surfaces.

Wire it into a page

// src/app/page.tsx
import Chat from '@/app/chat';

export default function Home() {
  return (
    <main className="min-h-screen bg-gray-50">
      <header className="border-b bg-white">
        <div className="max-w-2xl mx-auto px-4 py-6">
          <h1 className="text-2xl font-semibold">Vercel AI SDK + n4n.ai</h1>
          <p className="text-gray-600 mt-1">Streaming Claude 3.5 Sonnet with tool calls</p>
        </div>
      </header>
      <Chat />
    </main>
  );
}

Run the dev server:

pnpm dev

Open http://localhost:3000. Try: “What’s the weather in Denver?” — you’ll see the model call getWeather, your execute function run, and the final response stream back.

Expected output checkpoints

First token latency: ~300-800ms depending on gateway load and geographic proximity.

Streaming chunks: Text arrives in 10-50 token increments. The cursor () in the UI blinks while status === 'streaming'.

Tool call flow (visible in the expanded “Tool calls” section):

[
  {
    "toolCallId": "call_abc123",
    "toolName": "getWeather",
    "args": { "location": "Denver, CO", "unit": "fahrenheit" },
    "state": "call"
  },
  {
    "toolCallId": "call_abc123",
    "toolName": "getWeather",
    "args": { "location": "Denver, CO", "unit": "fahrenheit" },
    "state": "result",
    "result": { "location": "Denver, CO", "temperature": 42, "unit": "fahrenheit", "condition": "Partly cloudy" }
  }
]

Final message: The assistant’s natural-language response incorporating the tool result.

Usage metadata (logged server-side if you add a onFinish callback):

{
  "promptTokens": 142,
  "completionTokens": 87,
  "totalTokens": 229
}

Error handling and fallbacks

The gateway returns standard HTTP codes. Handle them at the route level:

// src/app/api/chat/route.ts (additions)
import { GatewayTimeoutError } from 'ai';

export async function POST(req: Request) {
  try {
    // ... existing streamText call
  } catch (err) {
    if (err instanceof GatewayTimeoutError) {
      return new Response('Gateway timeout', { status: 504 });
    }
    if (err instanceof Error && err.message.includes('rate_limit')) {
      return new Response('Rate limited', { status: 429 });
    }
    return new Response('Internal error', { status: 500 });
  }
}

The AI SDK surfaces provider errors as APICallError with statusCode and responseBody. n4n.ai forwards upstream provider error shapes, so Anthropic’s overloaded_error or rate_limit_error come through intact.

For automatic fallback across providers, you’d configure that in the n4n.ai dashboard (routing rules, priority lists). The SDK side stays the same — you still call sonnet and the gateway handles the swap.

Production considerations

Caching: n4n.ai forwards Cache-Control hints from upstream. For deterministic prompts (classification, extraction), add cacheControl: { type: 'ephemeral' } to the streamText options to enable provider-side prompt caching where supported.

Observability: Wrap streamText with your tracing SDK (OpenTelemetry, Datadog, etc.). The onFinish callback receives { usage, finishReason, response } — log these per request.

Rate limiting: Implement per-user quotas at your API layer (Next.js middleware, Upstash Redis, etc.). The gateway enforces its own limits, but you want backpressure before requests leave your network.

Model pinning: In gateway.ts, pin to a dated snapshot (anthropic/claude-3.5-sonnet-20241022) to avoid silent behavior changes. Update intentionally during a deploy.

Testing the tool loop locally

You can exercise the full tool cycle without a browser:

curl -X POST http://localhost:3000/api/chat \
  -H "Content-Type: application/json" \
  -d '{"messages":[{"role":"user","content":"Weather in Tokyo?"}]}'

Response is text/plain SSE. Look for data: {"type":"tool-call",...} and data: {"type":"tool-result",...} events before the final text deltas.

Summary

You now have a minimal, production-shaped stack:

  1. Gateway client (src/lib/gateway.ts) — single source of truth for auth, base URL, and model IDs.
  2. Streaming route (src/app/api/chat/route.ts) — streamText with tools, system prompt, and error boundaries.
  3. React hook (src/app/chat.tsx) — useChat handles state, streaming, and tool round-trips.
  4. Observability hooksonFinish, onError, and response metadata for logging.

Swap sonnet for gateway('openai/gpt-4o') or gateway('google/gemini-1.5-pro') and the rest of the code doesn’t change. That’s the point of the gateway pattern: your application code stays provider-agnostic while the routing layer handles fallbacks, caching, and cost optimization.

Tagsvercel-ai-sdkclaude-3-5-sonnetn4n-aisetup

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All vercel ai sdk getting started with n4n.ai posts →