Writing a Production Custom Tool from Scratch and Connecting It to Your AI Agent

Writing a Production Custom Tool from Scratch and Connecting It to Your AI Agent

Z
Zahid Hasan Tonmoy
October 21, 2026
15 min read
🇧🇩 বাংলায় পড়ুন
|
0 views
Audio Overview & Podcast
~1 min quick overview
0%

Writing a Production Custom Tool from Scratch and Connecting It to Your AI Agent
Writing a Production Custom Tool from Scratch and Connecting It to Your AI Agent

In our previous guide on building a multi-step reasoning AI agent in Next.js with LangChain.js, we orchestrated sequential tool calls using agent_scratchpad memory so our DevPulse agent could verify documentation rules before checking bug reports. However, if you look inside our tools.ts file from earlier posts, the tools themselves are still returning static hardcoded objects. For an AI agent to solve real engineering problems, it needs custom tools that reach out to live external APIs, databases, and microservice health endpoints safely.

To write a production-ready custom tool and connect it to an AI agent in Next.js, you wrap your external API or database logic inside a server-only TypeScript function guarded by four layers: a strict Zod input allowlist, an AbortSignal.timeout() circuit breaker, a response DTO trimmer that strips noisy JSON fields to save context tokens, and a structured error envelope (retryable: boolean). You can then bind that single custom executor to both the Vercel AI SDK (tool()) and LangChain.js (DynamicStructuredTool) without duplicating business logic.

Most beginner tutorials treat a custom tool as a five-line fetch() wrapper that dumps whatever await res.json() returns straight back into the LLM. That works in a toy demo, but the first time you point an unguarded fetch() tool at a real third-party API like GitHub or an internal service gateway, you expose your backend to Server-Side Request Forgery (SSRF), hanging serverless functions, and 10,000-token context explosions.

The 4 Engineering Pillars of a Production Custom Tool

When a human frontend calls a REST API, a slow response or a bloated 50-kilobyte JSON payload is merely annoying. When an AI agent calls a custom tool mid-loop, every byte returned by that function is injected directly into the LLM's prompt window for every subsequent step of the reasoning chain.

Before writing code, every custom tool you design should enforce four concrete guardrails:

  1. Input Allowlisting (Preventing SSRF and Injection): Never give an agent a tool with an open url: z.string() parameter. If a prompt injection tricks the model into requesting http://localhost:3000/admin or cloud metadata IPs (169.254.169.254), your server will happily fetch its own internal secrets. Always restrict inputs to narrow enums, numeric IDs, or validated slug patterns.
  2. Hard Execution Timeouts (AbortSignal.timeout): Third-party APIs stall. If your custom tool waits 25 seconds for a slow upstream server, your Next.js Server Action or /api/chat route will hit its serverless timeout and crash the entire conversation turn. Cap every external tool call at 4,000 to 5,000 milliseconds.
  3. Context-Window Shaping (DTO Projection): A single response from the GitHub Issues REST API contains over 60 fields—including avatar_url, events_url, node_id, reactions, and massive markdown logs. Returning raw external JSON wastes thousands of input tokens and confuses the model. Always map external responses down to a compact Data Transfer Object (DTO) under 300 tokens.
  4. Self-Healing Error Instructions: When an API returns a 404 Not Found or 429 Rate Limit, throwing an uncaught exception kills the agent loop. Returning a structured object with ok: false, retryable: false, and an explicit agentInstruction string tells the LLM how to proceed without spinning in retry loops.

3D glassmorphism visualization of a four-stage custom tool pipeline trimming a chaotic raw API payload into a compact glowing cyan data cube for the AI agent
3D glassmorphism visualization of a four-stage custom tool pipeline trimming a chaotic raw API payload into a compact glowing cyan data cube for the AI agent

Designing Our Custom Tool: inspectLiveGitHubIssue

To make our DevPulse triage agent genuinely useful for engineering teams, we are going to build a live custom tool called inspectLiveGitHubIssue alongside a live service health probe called probeGatewayEndpoint.

Instead of returning fake hardcoded strings, inspectLiveGitHubIssue connects to the real GitHub REST API ([https://api.github.com/repos/](https://api.github.com/repos/)...), fetches live issue metadata and comments for an allowlisted repository, enforces a 5-second timeout, truncates oversized stack traces, and feeds a clean diagnostic summary back to Claude 3.5 Sonnet or GPT-4o.

Here is the execution pipeline inside our custom tool architecture:

`mermaid flowchart TD Agent([AI Agent Orchestrator Vercel AI SDK / LangChain.js]) -->|Raw Tool Arguments| Guard[Layer 1: Zod Schema & Repo Allowlist Guard] Guard -->|Invalid Input| Err ValidationError[Return Structured Non-Retryable Error] Guard -->|Validated Args| Fetch[Layer 2: External Fetch with AbortSignal.timeout 5000ms]

Fetch -->|HTTP 200 JSON| Shaper[Layer 3: DTO Shaper & Markdown Truncator < 600 chars]
Fetch -->|Timeout / HTTP 4xx 5xx| Handler[Layer 4: Error Classifier

retryable: true / false]

Shaper --> Output[Compact JSON Observation ~180 Tokens]
Handler --> Output
Err --> Output
Output --> Agent

`

Architecture Flow Summary: When the agent orchestrator invokes the custom tool, the request passes through a four-layer pipeline: Layer 1 validates arguments against a Zod schema and repository allowlist; Layer 2 executes the external HTTP request with a strict 5,000ms AbortSignal.timeout; Layer 3 strips noisy API metadata and truncates long markdown bodies into a ~180-token DTO; and Layer 4 catches timeouts or HTTP errors and returns a structured observation with a retryable flag back to the agent.

Step 1: Writing the Core Custom Tool Executor (custom-github-tool.ts)

Let us create src/lib/agent/custom-github-tool.ts. We design this module with zero dependency on any specific AI framework—just pure TypeScript, Zod, and native fetch—so we can plug it into both the Vercel AI SDK and LangChain.js.

`typescript // src/lib/agent/custom-github-tool.ts import 'server-only'; import { z } from 'zod';

const ALLOWED_REPOSITORIES = [ 'vercel/next.js', 'vercel/ai', 'langchain-ai/langchainjs', ] as const;

export const inspectGitHubIssueSchema = z.object({ repository: z .enum(ALLOWED_REPOSITORIES) .describe( 'Allowlisted GitHub repository in owner/name format to inspect.' ), issueNumber: z .number() .int() .positive() .max(999999) .describe('The exact numeric GitHub issue or pull request ID (e.g., 402).'), });

export type InspectGitHubIssueInput = z.infer;

export interface SanitizedGitHubIssueDTO { repository: string; issueNumber: number; title: string; state: string; author: string; labels: string[]; createdAt: string; commentsCount: number; bodyExcerpt: string; htmlUrl: string; }

export interface CustomToolEnvelope { ok: boolean; toolName: string; retryable: boolean; durationMs: number; data: T | null; agentInstruction?: string; }

interface RawGitHubIssueResponse { number: number; title: string; state: string; user?: { login?: string }; labels?: Array<{ name?: string } | string>; created_at: string; comments: number; body?: string | null; html_url: string; }

function truncateCleanText(raw: string | null | undefined, maxLength = 600): string { if (!raw) return 'No description provided.'; const collapsed = raw.replace(/\r\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim(); if (collapsed.length <= maxLength) return collapsed; return ${collapsed.slice(0, maxLength)}... [Truncated to save context tokens]; }

export async function executeInspectGitHubIssue( rawInput: unknown ): Promise<CustomToolEnvelope> { const startTime = Date.now(); const parsed = inspectGitHubIssueSchema.safeParse(rawInput);

if (!parsed.success) { return { ok: false, toolName: 'inspectLiveGitHubIssue', retryable: false, durationMs: Date.now() - startTime, data: null, agentInstruction: Validation failed: ${parsed.error.issues .map((i) => i.message) .join('; ')}. Use an allowlisted repository and a positive integer., }; }

const { repository, issueNumber } = parsed.data; const endpoint = https://api.github.com/repos/${repository}/issues/${issueNumber};

const headers: Record<string, string> = { Accept: 'application/vnd.github+json', 'User-Agent': 'DevPulse-AI-Agent/1.0', };

if (process.env.GITHUB_PAT_TOKEN) { headers.Authorization = Bearer ${process.env.GITHUB_PAT_TOKEN}; }

try { const response = await fetch(endpoint, { method: 'GET', headers, signal: AbortSignal.timeout(5000), next: { revalidate: 120 }, });

if (response.status === 404) {
  return {
    ok: false,
    toolName: 'inspectLiveGitHubIssue',
    retryable: false,
    durationMs: Date.now() - startTime,
    data: null,
    agentInstruction: `Issue #${issueNumber} does not exist in ${repository}. Do not retry the same issue number.`,
  };
}

if (response.status === 403 || response.status === 429) {
  return {
    ok: false,
    toolName: 'inspectLiveGitHubIssue',
    retryable: false,
    durationMs: Date.now() - startTime,
    data: null,
    agentInstruction:
      'GitHub API rate limit reached. Inform the user and answer using existing documentation context.',
  };
}

if (!response.ok) {
  return {
    ok: false,
    toolName: 'inspectLiveGitHubIssue',
    retryable: true,
    durationMs: Date.now() - startTime,
    data: null,
    agentInstruction: `Upstream GitHub returned HTTP ${response.status}. You may retry once or fallback to local issue tracker.`,
  };
}

const rawJson = (await response.json()) as RawGitHubIssueResponse;

const cleanLabels = Array.isArray(rawJson.labels)
  ? rawJson.labels
      .map((lbl) => (typeof lbl === 'string' ? lbl : lbl.name ?? ''))
      .filter(Boolean)
  : [];

const shapedDto: SanitizedGitHubIssueDTO = {
  repository,
  issueNumber: rawJson.number,
  title: rawJson.title,
  state: rawJson.state,
  author: rawJson.user?.login ?? 'unknown',
  labels: cleanLabels.slice(0, 8),
  createdAt: rawJson.created_at,
  commentsCount: rawJson.comments,
  bodyExcerpt: truncateCleanText(rawJson.body, 600),
  htmlUrl: rawJson.html_url,
};

return {
  ok: true,
  toolName: 'inspectLiveGitHubIssue',
  retryable: false,
  durationMs: Date.now() - startTime,
  data: shapedDto,
};

} catch (err) { const isTimeout = err instanceof Error && (err.name === 'TimeoutError' || err.name === 'AbortError');

return {
  ok: false,
  toolName: 'inspectLiveGitHubIssue',
  retryable: false,
  durationMs: Date.now() - startTime,
  data: null,
  agentInstruction: isTimeout
    ? 'GitHub API timed out after 5000ms. Do not retry this tool in the current turn.'
    : 'Network error while reaching GitHub API. Proceed with available context.',
};

} } `

Look closely at three production details inside executeInspectGitHubIssue:

  • Repository Enum Allowlist (ALLOWED_REPOSITORIES): The LLM cannot pass arbitrary URLs or path traversal strings like ../../internal-org/private-repo. Zod strictly validates that repository matches one of our approved strings.
  • Next.js Fetch Caching (next: { revalidate: 120 }): If three developers ask your agent about Issue #1234 within two minutes, Next.js serves the cached GitHub response in 2 milliseconds without burning your GitHub API rate limit.
  • Token-Shaped DTO (SanitizedGitHubIssueDTO): Even if an issue body on GitHub has 25,000 characters of terminal crash logs, truncateCleanText(rawJson.body, 600) caps the excerpt at 600 characters so the LLM gets the exact problem summary without blowing up your prompt token budget.

Step 2: Binding the Custom Tool to Both Vercel AI SDK and LangChain.js

Because we wrote executeInspectGitHubIssue as a clean, framework-agnostic function, we can attach it to both our streaming Vercel AI SDK agent (from Post 17) and our multi-step LangChain.js agent (from Post 18) in less than twenty lines of code.

First, register the custom tool inside src/lib/agent/vercel-tools.ts:

`typescript // src/lib/agent/vercel-tools.ts import 'server-only'; import { tool } from 'ai'; import { searchDocsSchema, checkIssuesSchema, draftTicketSchema, } from './blueprint'; import { executeAgentTool } from './tools'; import { inspectGitHubIssueSchema, executeInspectGitHubIssue, } from './custom-github-tool';

export const devPulseTools = { searchKnowledgeBase: tool({ description: 'Search internal technical documentation and architecture guides by semantic query.', parameters: searchDocsSchema, execute: async (args) => executeAgentTool('searchKnowledgeBase', args), }),

checkIssueTracker: tool({ description: 'Query internal project issue tracker for open or closed bug reports matching a keyword.', parameters: checkIssuesSchema, execute: async (args) => executeAgentTool('checkIssueTracker', args), }),

inspectLiveGitHubIssue: tool({ description: 'Fetch live metadata, status, labels, and truncated body for a specific numeric issue ID in an allowlisted upstream GitHub repository (vercel/next.js, vercel/ai, or langchain-ai/langchainjs).', parameters: inspectGitHubIssueSchema, execute: async (args) => executeInspectGitHubIssue(args), }),

draftIssueTicket: tool({ description: 'Create a structured bug ticket draft requiring Level 2 human confirmation.', parameters: draftTicketSchema, execute: async (args) => executeAgentTool('draftIssueTicket', args), }), }; `

Second, add the exact same custom tool to our LangChain.js registry in src/lib/agent/langchain-tools.ts:

`typescript // src/lib/agent/langchain-tools.ts (Adding the custom GitHub tool) import { tool } from '@langchain/core/tools'; import { inspectGitHubIssueSchema, executeInspectGitHubIssue, } from './custom-github-tool';

export const inspectGitHubIssueLangChainTool = tool( async (input) => { const result = await executeInspectGitHubIssue(input); return JSON.stringify(result); }, { name: 'inspectLiveGitHubIssue', description: 'Fetch live issue status, labels, and truncated description from an allowlisted upstream GitHub repository by numeric issue ID.', schema: inspectGitHubIssueSchema, } ); `

Step 3: Rendering Custom Tool Results in the React UI

In Post 17, our DevPulseStreamChat.tsx component rendered generic purple boxes for tool calls. Now that we have a rich custom tool returning structured SanitizedGitHubIssueDTO data, we can render a dedicated, interactive GitHub Issue preview card inside the chat stream whenever toolName === 'inspectLiveGitHubIssue'.

Create a modular presenter component in src/components/GitHubToolCard.tsx:

`tsx // src/components/GitHubToolCard.tsx 'use client';

import { type CustomToolEnvelope, type SanitizedGitHubIssueDTO, } from '@/lib/agent/custom-github-tool';

interface GitHubToolCardProps { state: 'call' | 'partial-call' | 'result'; args: unknown; result?: CustomToolEnvelope; }

export default function GitHubToolCard({ state, args, result, }: GitHubToolCardProps) { if (state !== 'result' || !result) { return ( ⚡ Probing Live GitHub API... {JSON.stringify(args)} ); }

if (!result.ok || !result.data) { return ( ⚠ Custom Tool Notice ({result.durationMs}ms) {result.agentInstruction} ); }

const issue = result.data;

return ( {issue.repository} #{issue.issueNumber} <span className={px-2 py-0.5 rounded uppercase text-[10px] font-bold ${ issue.state === 'open' ? 'bg-emerald-950 text-emerald-300 border border-emerald-700' : 'bg-purple-950 text-purple-300 border border-purple-700' }} > {issue.state} • {result.durationMs}ms

  <div className='text-sm font-medium text-slate-100'>{issue.title}</div>

  {issue.labels.length > 0 && (
    <div className='flex flex-wrap gap-1.5'>
      {issue.labels.map((label) => (
        <span
          key={label}
          className='px-2 py-0.5 rounded bg-slate-900 border border-slate-700 text-[10px] text-slate-300'
        >
          {label}
        </span>
      ))}
    </div>
  )}

  <p className='text-slate-400 line-clamp-3 whitespace-pre-wrap'>
    {issue.bodyExcerpt}
  </p>
</div>

); } `

Dark frosted glass React chat component showing a structured live GitHub issue inspection card with an emerald status pill and purple label capsules
Dark frosted glass React chat component showing a structured live GitHub issue inspection card with an emerald status pill and purple label capsules

A Real Lesson from Building My GitHub Issues Tracker and External API Integrations

When I built my GitHub Issues Tracker web app and worked with external REST endpoints like the TVMaze API in Movie Explorer, I saw firsthand how verbose public REST APIs are. In a traditional frontend app, if the GitHub API returns a 45-kilobyte JSON object with seventy nested properties (followers_url, gists_url, organizations_url, reactions, and a 4,000-word bug log), the browser parses it into memory and your UI component simply reads issue.title and issue.state. The extra fields cost you nothing except a few kilobytes of network bandwidth.

When I first turned that exact GitHub fetch function into a custom AI agent tool without adding a DTO shaper, it was a disaster. I asked the agent to compare three upstream issues in a single turn. My custom tool fetched all three raw GitHub JSON payloads and returned them directly to the model. That single turn injected more than 14,000 tokens of useless avatar URLs, API hypermedia links, and raw stack traces into the LLM context window—spiking latency to 11 seconds and costing more than ten normal chat turns combined!

Even worse, when one of the external requests stalled for nine seconds on a shaky connection, the tool had no AbortSignal.timeout(), causing the user to stare at a frozen screen. Adding SanitizedGitHubIssueDTO to strip out hypermedia noise, truncating bodyExcerpt to 600 characters, and enforcing AbortSignal.timeout(5000) cut the per-call token footprint from ~4,500 tokens down to ~180 tokens and made the agent respond nearly three times faster.

Common Mistakes When Writing Custom Tools for AI Agents

Before moving to Post 20 (where we will build granular Thinking and Loading states for these tools in the UI), audit every custom tool you write against these four mistakes:

1. Returning Raw Third-Party API Responses Without a DTO Shaper

Never write return await response.json() at the end of a custom tool that talks to a third-party REST or GraphQL API. External APIs are designed for machines, not LLM context windows. Always map the response down to 5–10 essential fields and cap free-text fields with a helper like truncateCleanText().

2. Accepting Arbitrary URLs or SQL Strings in Tool Parameters

If you create a tool with url: z.string().url(), an attacker or a hallucinating model can pass internal network addresses or unauthorized third-party domains. Always construct the base URL on the server and restrict the LLM's input to an explicit z.enum(...) allowlist or validated entity IDs.

3. Calling fetch() Without AbortSignal.timeout()

Node.js's native fetch can wait up to 300 seconds for a stalled socket by default—far longer than your Vercel serverless function timeout (60s). Always attach signal: AbortSignal.timeout(5000) so a hung external service fails fast and lets your agent fall back gracefully.

4. Throwing Raw Exceptions Instead of Returning agentInstruction

When a user asks about Issue #9999999 and GitHub returns 404 Not Found, throwing new Error('404') either crashes your stream or gives the LLM a cryptic stack trace, causing it to retry the same invalid ID again. Returning { ok: false, retryable: false, agentInstruction: 'Issue does not exist. Do not retry.' } lets the model immediately explain to the user that the issue number was not found.

Frequently Asked Questions

How many custom tools can I attach to a single AI agent before accuracy drops?

In production with Claude 3.5 Sonnet and GPT-4o, a single agent handles between 4 and 12 distinct tools with high routing accuracy, provided their descriptions and parameter schemas do not overlap. Once your system grows beyond 15–20 tools, tool descriptions compete for attention in the system prompt, and it becomes much more effective to split them across specialized sub-agents.

Can a custom tool access the current logged-in user's session or cookies securely?

Yes, but you should never ask the LLM to pass userId or auth tokens inside the tool's Zod parameters (since a prompt injection could spoof another user's ID). Instead, read the authenticated user session directly from Next.js cookies() or headers() inside the server-side execute function, keeping authentication completely invisible to the LLM.

Why use AbortSignal.timeout(5000) instead of Promise.race with setTimeout?

AbortSignal.timeout(5000) is built natively into modern Node.js (v18+) and Edge runtimes. Unlike Promise.race, which only ignores the slow promise while leaving the underlying TCP socket open in the background, passing signal: AbortSignal.timeout(5000) to fetch() actively aborts the HTTP request and frees server memory immediately.

🟢 Available for Freelance & Contract Work

Need a High-Performance Web App or Custom AI Solution?

I help founders, businesses, and engineering teams build lightning-fast web applications, resilient backend architectures, and intelligent AI workflows. Have an idea in mind? Let’s bring it to life.

Fast MVP Launch (2–4 weeks)
AI Agent & LLM API Integration
100/100 Core Web Vitals & SEO
Production-Ready Clean Architecture

Found this article helpful?

Give some claps to support more in-depth engineering logs!

0 views
Z

Zahid Hasan Tonmoy

Author & Developer

MERN Full Stack Developer & AI Agent Developer based in Dhaka, Bangladesh. Writing about web development, React, PostgreSQL and my learning journey.

Related Articles

Stay Updated

Subscribe to get insights on full-stack architecture, AI agent engineering, and web development.