docs
Cookbooks

Vercel AI SDK

Connect a TypeScript agent on the Vercel AI SDK to nima's MCP server and file what it finds.

By the end of this page you have a TypeScript agent on the Vercel AI SDK that connects to https://mcp.getnima.io/mcp with a personal API key, loads nima's tools with client.tools(), calls ask_knowledge_graph and search_code before it answers, and files a signal with create_signal when the key allows it. The same agent over plain HTTP, with no MCP client, is at the end.

Availability

On for every workspace. ask_knowledge_graph needs no plan feature. search_code is declared under the plan feature code_intelligence; a workspace whose plan lacks it can get 403 INSUFFICIENT_PERMISSIONS with details.feature set, so handle that answer.

Prerequisites for the Vercel AI SDK cookbook

WhatValue
nima keyA personal API key (nima_…), created in the admin panel under Settings → API keys. Tick knowledge:read for ask_knowledge_graph and search_code. Add signals:write for create_signal. See Authentication.
Model keyANTHROPIC_API_KEY, read by @ai-sdk/anthropic by default.
RuntimeNode.js with ESM. The files below use import.meta.url.

Install the AI SDK, its MCP client and the Anthropic provider:

pnpm add ai @ai-sdk/mcp @ai-sdk/anthropic zod
pnpm add -D tsx typescript @types/node
npm pkg set type=module

Put both keys in the environment:

export NIMA_API_KEY="nima_…"
export ANTHROPIC_API_KEY="sk-ant-…"

createMCPClient is imported from @ai-sdk/mcp. The AI SDK docs no longer use an experimental_ prefix for it. @modelcontextprotocol/sdk is optional and not needed here.

Connect the Vercel AI SDK to nima's MCP server

The AI SDK's built-in HTTP transport takes a headers object, which is where the bearer goes. This file connects, lists the tools nima advertises to your key, and closes the client.

connect.ts
import { pathToFileURL } from 'node:url';
import { createMCPClient, type MCPClient } from '@ai-sdk/mcp';

const NIMA_MCP_URL = 'https://mcp.getnima.io/mcp';

export async function connectNima(): Promise<MCPClient> {
  const apiKey = process.env.NIMA_API_KEY;
  if (!apiKey) {
    throw new Error('Set NIMA_API_KEY to a personal nima API key (nima_…).');
  }

  return createMCPClient({
    transport: {
      type: 'http',
      url: NIMA_MCP_URL,
      headers: { Authorization: `Bearer ${apiKey}` },
    },
  });
}

async function main(): Promise<void> {
  let client: MCPClient | undefined;
  try {
    client = await connectNima();
    const tools = await client.tools();
    console.log(Object.keys(tools).sort().join('\n'));
  } finally {
    await client?.close();
  }
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  main().catch((error) => {
    console.error(error);
    process.exitCode = 1;
  });
}
npx tsx connect.ts

The list is longer than the scopes on your key suggest. nima's tools/list shows every tool behind a consent-screen scope even when the key does not hold that scope, so a default key sees all of them, including ask_knowledge_graph, search_code, search_signals, create_signal and the watch tools. Tools behind the headless scopes (operations:write, integrations:write, actions:approve, identities:write) appear only when the key holds the scope. The tool-to-scope table is on Connect nima to Claude; the tools themselves are on Knowledge tools and Pipeline tools.

Because a tool can be listed without the scope to call it, client.tools() does not tell you whether a call will work. A call to a tool the key cannot use fails with 403 insufficient_scope. Check scopes when you create the key, not by looking for the tool.

The AI SDK docs also show StreamableHTTPClientTransport from @modelcontextprotocol/sdk/client/streamableHttp.js, passed as transport: new StreamableHTTPClientTransport(url, { sessionId: 'session_123' }). That example sets no headers, so this page uses the built-in type: 'http' transport, which the docs show with headers. nima's endpoint keeps no sessions, so there is no session id to pass either way.

Ask the knowledge graph and code search from a Vercel AI SDK agent

The agent gets two tools only, ask_knowledge_graph for why and who and search_code for where, and instructions to call both before it answers.

agent.ts
import { pathToFileURL } from 'node:url';
import { anthropic } from '@ai-sdk/anthropic';
import { createMCPClient, type MCPClient } from '@ai-sdk/mcp';
import { generateText, isStepCount } from 'ai';

const INSTRUCTIONS = [
  'Before you answer, call ask_knowledge_graph with the feature name to learn who asked and why,',
  'then call search_code to find where it lives.',
  'Compare the lastCommitSha that search_code returns with the commit you are working on and say when the index is behind.',
  'Quote what the tools return. If they return nothing, say that, and do not treat it as proof that nothing exists.',
].join(' ');

async function main(task: string): Promise<void> {
  const apiKey = process.env.NIMA_API_KEY;
  if (!apiKey) {
    throw new Error('Set NIMA_API_KEY to a personal nima API key (nima_…).');
  }

  let client: MCPClient | undefined;
  try {
    client = await createMCPClient({
      transport: {
        type: 'http',
        url: 'https://mcp.getnima.io/mcp',
        headers: { Authorization: `Bearer ${apiKey}` },
      },
    });

    const { ask_knowledge_graph, search_code } = await client.tools();
    if (!ask_knowledge_graph || !search_code) {
      throw new Error('nima did not advertise ask_knowledge_graph and search_code.');
    }

    const { text } = await generateText({
      model: anthropic('claude-opus-5-5'),
      instructions: INSTRUCTIONS,
      tools: { ask_knowledge_graph, search_code },
      stopWhen: isStepCount(6),
      prompt: task,
      onToolExecutionStart({ toolCall }) {
        console.error(`-> ${toolCall.toolName}`, JSON.stringify(toolCall.input));
      },
    });

    console.log(text);
  } finally {
    await client?.close();
  }
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  const task = process.argv.slice(2).join(' ') || 'The weekly CSV import stalls on large files. What should I change?';
  main(task).catch((error) => {
    console.error(error);
    process.exitCode = 1;
  });
}

To use another provider, change the import and the model line, for example import { openai } from '@ai-sdk/openai' with model: openai('…').

npx tsx agent.ts "The weekly CSV import stalls on large files. What should I change?"

The two calls the model makes, as logged by onToolExecutionStart:

-> ask_knowledge_graph {"q":"why does the weekly CSV import stall on large files","limit":5}
-> search_code {"q":"where is the CSV import chunk size set","limit":5}

Read the ask_knowledge_graph and search_code results

ask_knowledge_graph returns markdown by default: one block per matching entity, with its modality, similarity, facts and relations. Trimmed (a real answer carries more facts and relations, and each entity header ends with the entity id; a header gains (exact) or (no exact match) when your question named a literal path, symbol or quoted term):

  Knowledge graph results for: "why does the weekly CSV import stall on large files"

  ### Streaming importer (feature) [implemented] · id: 9d2f4c61-0b7e-4a3d-8f15-6c2e9a7b3d40
  References: 6 | Similarity: 0.742
  Summary: Chunked CSV import that replaced the legacy importer.
  Facts:
    - shipped_at: 2026-03-19 (source: PR:5c7d9e13-2a4b-4e6f-8c1d-3b5a7e9f0c22, learned 2026-03-19)
  Relations:
    - requested → Northwind (customer) — "asked twice in Q1 for imports above 50 MB"

search_code returns JSON. Trimmed:

{
  "query": "where is the CSV import chunk size set",
  "repositories": [
    {
      "id": "3f8b0a41-6c2d-4f19-9ab7-15d0e2c7a884",
      "repoFullName": "acme/orbit",
      "indexStatus": "ready",
      "lastCommitSha": "b41c9de2f7a1c05b8e3d6410a9f2c7d3e5b81a60"
    }
  ],
  "droppedRepositories": [],
  "results": [
    {
      "filePath": "src/import/stream.ts",
      "score": 0.79,
      "match": "semantic",
      "summary": "Reads an uploaded CSV in fixed-size chunks.",
      "symbols": [
        { "name": "readChunks", "kind": "function", "lineStart": 18, "lineEnd": 64 }
      ]
    }
  ]
}

The routes behind the two tools are documented on Knowledge graph and Code.

File a finding from the Vercel AI SDK agent with create_signal

create_signal files one signal into the pipeline, and it needs a key with signals:write. Give the tool to the agent on its own, and stop the loop the moment it is called.

file-signal.ts
import { pathToFileURL } from 'node:url';
import { anthropic } from '@ai-sdk/anthropic';
import { createMCPClient, type MCPClient } from '@ai-sdk/mcp';
import { generateText, hasToolCall, isStepCount } from 'ai';

async function main(finding: string): Promise<void> {
  const apiKey = process.env.NIMA_API_KEY;
  if (!apiKey) {
    throw new Error('Set NIMA_API_KEY to a personal nima API key (nima_…).');
  }

  let client: MCPClient | undefined;
  try {
    // No maxRetries here: a retried create_signal files a second signal.
    client = await createMCPClient({
      transport: {
        type: 'http',
        url: 'https://mcp.getnima.io/mcp',
        headers: { Authorization: `Bearer ${apiKey}` },
      },
    });

    const { create_signal } = await client.tools();
    if (!create_signal) {
      throw new Error('nima did not advertise create_signal.');
    }

    const { steps } = await generateText({
      model: anthropic('claude-opus-5-5'),
      instructions:
        'File exactly one signal with create_signal. Put the finding in content, in plain words. ' +
        'Set sourceLabel to "vercel ai sdk agent". Fill stakeholders and productAreas only when the finding names them.',
      tools: { create_signal },
      stopWhen: [hasToolCall('create_signal'), isStepCount(3)],
      prompt: finding,
    });

    const calls = steps.flatMap((step) => step.toolCalls);
    console.log(JSON.stringify(calls.map((call) => call.input), null, 2));
  } finally {
    await client?.close();
  }
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  const finding =
    process.argv.slice(2).join(' ') ||
    'On the Northwind call: the weekly CSV import dies around 40k rows with a 504. They are splitting the file by hand.';
  main(finding).catch((error) => {
    console.error(error);
    process.exitCode = 1;
  });
}

The input the model sent:

[
  {
    "content": "On the Northwind call: the weekly CSV import dies around 40k rows with a 504. They are splitting the file by hand.",
    "sourceLabel": "vercel ai sdk agent",
    "stakeholders": ["Northwind"],
    "productAreas": ["import"]
  }
]

content is required, 1 to 10000 characters. sourceLabel is up to 100 characters and defaults to manual. stakeholders and productAreas each take up to 10 strings of up to 80 characters. The signal is created with status pending, and nima classifies and de-duplicates it afterwards. Fetch it with get_signal if you need the outcome.

Do not retry create_signal

The AI SDK can retry tools/call when you pass maxRetries to createMCPClient, and its docs warn against it for tools that create records. POST /v1/signals has no idempotency key, so a retried call files a second signal. Keep retries on a separate client that holds only the read tools.

Call nima over REST from the Vercel AI SDK instead of MCP

When you would rather not speak MCP, wrap the routes behind the tools in AI SDK tool() definitions. Same key, same header, base URL https://api.getnima.io.

agent-rest.ts
import { pathToFileURL } from 'node:url';
import { anthropic } from '@ai-sdk/anthropic';
import { generateText, isStepCount, tool } from 'ai';
import { z } from 'zod';

const NIMA_API = 'https://api.getnima.io';

function nimaHeaders(): Record<string, string> {
  const apiKey = process.env.NIMA_API_KEY;
  if (!apiKey) {
    throw new Error('Set NIMA_API_KEY to a personal nima API key (nima_…).');
  }
  return { Authorization: `Bearer ${apiKey}` };
}

async function nimaGet(path: string, params: Record<string, string>): Promise<string> {
  const url = new URL(path, NIMA_API);
  for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
  const response = await fetch(url, { headers: nimaHeaders() });
  const body = await response.text();
  if (!response.ok) {
    return `nima answered ${response.status} (X-Request-Id ${response.headers.get('x-request-id')}): ${body.slice(0, 500)}`;
  }
  return body;
}

const tools = {
  ask_knowledge_graph: tool({
    description: 'Ask what the workspace knows about the product: who asked, why, what was decided. Returns markdown.',
    inputSchema: z.object({
      q: z.string().min(2).max(500).describe('A plain English question'),
      limit: z.number().int().min(1).max(20).optional(),
    }),
    execute: async ({ q, limit }) =>
      nimaGet('/v1/knowledge-graph/ask', { q, ...(limit ? { limit: String(limit) } : {}) }),
  }),
  search_code: tool({
    description: 'Find code in the indexed repositories: files, summaries, symbols, line ranges, and the indexed commit.',
    inputSchema: z.object({
      q: z.string().min(2).max(300).describe('Where is X, or a symbol name'),
      limit: z.number().int().min(1).max(20).optional(),
    }),
    execute: async ({ q, limit }) =>
      nimaGet('/v1/code-intel/search', { q, ...(limit ? { limit: String(limit) } : {}) }),
  }),
};

async function main(task: string): Promise<void> {
  const { text } = await generateText({
    model: anthropic('claude-opus-5-5'),
    instructions: 'Call ask_knowledge_graph, then search_code, before you answer. Quote what they return.',
    tools,
    stopWhen: isStepCount(6),
    prompt: task,
  });
  console.log(text);
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  const task = process.argv.slice(2).join(' ') || 'The weekly CSV import stalls on large files. What should I change?';
  main(task).catch((error) => {
    console.error(error);
    process.exitCode = 1;
  });
}

Filing over REST is one POST:

curl -s -X POST "https://api.getnima.io/v1/signals" \
  -H "Authorization: Bearer $NIMA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "On the Northwind call: the weekly CSV import dies around 40k rows with a 504. They are splitting the file by hand.",
    "sourceLabel": "vercel ai sdk agent",
    "stakeholders": ["Northwind"],
    "productAreas": ["import"]
  }'

It answers 201 with the created signal. The route and its parameters are on Signals.

Fix common Vercel AI SDK and nima MCP problems

SymptomCauseFix
A tool is in client.tools() but the call fails with 403 insufficient_scopenima lists every consent-scope tool to every key. The call re-checks the scope.Create a key that carries the scope. create_signal needs signals:write; the knowledge tools need knowledge:read.
A tool such as start_operation is not listedTools behind the headless scopes (operations:write, integrations:write, actions:approve, identities:write) appear only when the key holds the scope. A Settings key cannot carry the first two; only Slack /nima mcp-key mints them. No key or OAuth client can carry actions:approve or identities:write today; approvals happen on the Slack card.See Connect nima to Claude.
search_code answers 403The workspace's plan can lack the code_intelligence feature; the tool error text then carries INSUFFICIENT_PERMISSIONS with details.feature set to code_intelligence. A 403 with insufficient_scope means a missing scope instead.ask_knowledge_graph needs no plan feature and still works.
Empty results from search_codeOnly ready indexes are searched. droppedRepositories lists repositories whose search failed or timed out; a repository not yet ready shows its indexStatus in repositories.Read repositories, droppedRepositories and lastCommitSha before concluding the code is not there.
ask_knowledge_graph returns one line and no ### headingNothing matched, or the question could not be embedded. Both answer 200, with No product entities found for: … or Could not generate an embedding for: ….Test the text for a ### entity heading, not the status.
A tool call fails with no reasonA tool call gets 60 seconds over MCP. A timeout is a tool error with nothing attached.Narrow the question, or call the route over REST.
429 from a tool callEach tool call spends one request from the POST /mcp bucket (100 per 60 seconds, which initialize and tools/list also spend) and one from the tool's own route (300 per 10 seconds for these three tools). The practical ceiling is about 100 tool calls per minute per caller address, and every agent behind that address shares it. An HTTP 429 comes from /mcp. A limited inner route comes back as a tool error whose text starts 429.Back off and retry read tools. Do not retry create_signal. See Rate limits.
A second signal appearscreate_signal was retried, by maxRetries or by the model calling it twice.No retries on that client, and hasToolCall('create_signal') in stopWhen.
Session options do nothingnima's endpoint is stateless and never returns Mcp-Session-Id.Leave initialSessionId and sessionId unset.

On this page