Google Agent Development Kit for TypeScript: Build a Multi-Agent System from Scratch

Wednesday 22/04/2026

·10 min read
Share:

You picked an AI agent framework six months ago. It was fine. Then Google quietly released the TypeScript version of their Agent Development Kit, and now your tech lead is asking whether you should switch. You search for a decent tutorial and find three: the official docs, a conference announcement, and a 40-line demo that doesn't actually compose agents. That's the whole internet.

This is the hands-on guide I wanted when I tried Google ADK for TypeScript — a multi-agent tutorial that builds something real. We'll build a research assistant where a supervisor agent coordinates three specialist agents (web researcher, data analyst, report writer), wire up proper tool definitions, run it locally, and deploy to Cloud Run. No toy examples.

If you've used Vercel AI SDK for agents or the Claude Agent SDK, the ADK mental model is different — more structured, closer to the Python ADK, and opinionated about how agents hand work to each other.

Why Google ADK for TypeScript

ADK (Agent Development Kit) started as a Python library Google extracted from the agent infrastructure behind Gemini and Vertex AI. The TypeScript version mirrors that API closely and targets the same audience: developers building agents that will eventually run on Google Cloud with Gemini as the default model — but it works with any provider.

Three things make it interesting:

  • Agents compose like classes, not like prompt strings. A supervisor agent contains sub-agents. Tools are typed functions. There is no "describe-your-agent-in-a-system-prompt" pattern — structure is code.
  • Built-in handoffs. Handing a conversation from one specialist to another is a first-class primitive, not something you hack together with router prompts.
  • Runs anywhere. Locally with adk run, as an HTTP service, or on Cloud Run with a single deploy command.

The tradeoff: it's more opinionated than Vercel AI SDK. If you want to hand-roll the agent loop, this isn't the framework. If you want structure, it's great.

What we're building

A research assistant that answers questions like "What were the top three AI funding rounds in Q1 2026 and what do they have in common?" by coordinating three specialists:

  • WebResearcher — runs web searches and fetches pages.
  • DataAnalyst — takes raw findings and computes aggregations (totals, averages, patterns).
  • ReportWriter — turns analysis into a markdown report with citations.

A ResearchSupervisor agent decides which specialist to delegate to, in what order, and when the task is complete.

Install the ADK TypeScript SDK

pnpm install @google/adk @google/genai zod
pnpm install -D tsx typescript @types/node

You'll also need a Gemini API key — either a raw GEMINI_API_KEY from AI Studio or Vertex AI credentials. For local work I recommend the API key route.

# .env
GEMINI_API_KEY=your-key-here
BRAVE_SEARCH_API_KEY=your-brave-key

Define the tools first

In ADK, tools are just typed async functions. Define inputs with Zod, and the framework handles schema generation and validation.

// src/tools/web-search.ts
import { z } from 'zod'
import { defineTool } from '@google/adk'

export const webSearch = defineTool({
    name: 'web_search',
    description: 'Search the web for recent information. Returns up to 5 results.',
    input: z.object({
        query: z.string().describe('The search query'),
        recency: z.enum(['day', 'week', 'month', 'year']).default('month'),
    }),
    output: z.object({
        results: z.array(
            z.object({
                title: z.string(),
                url: z.string().url(),
                snippet: z.string(),
            })
        ),
    }),
    handler: async ({ query, recency }) => {
        const res = await fetch(
            `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&freshness=${recency}`,
            {
                headers: {
                    'X-Subscription-Token': process.env.BRAVE_SEARCH_API_KEY!,
                    Accept: 'application/json',
                },
            }
        )
        if (!res.ok) {
            throw new Error(`Brave search failed: ${res.status} ${res.statusText}`)
        }
        const data = (await res.json()) as {
            web?: { results?: Array<{ title: string; url: string; description: string }> }
        }
        return {
            results: (data.web?.results ?? []).slice(0, 5).map((r) => ({
                title: r.title,
                url: r.url,
                snippet: r.description,
            })),
        }
    },
})

One analysis tool for the DataAnalyst — keep it simple, let the LLM compose:

// src/tools/analyze.ts
import { z } from 'zod'
import { defineTool } from '@google/adk'

export const computeStats = defineTool({
    name: 'compute_stats',
    description: 'Compute sum, average, min, and max for an array of numbers.',
    input: z.object({
        values: z.array(z.number()).min(1),
        label: z.string().describe('What these numbers represent (e.g., "funding amounts in USD")'),
    }),
    output: z.object({
        label: z.string(),
        count: z.number(),
        sum: z.number(),
        average: z.number(),
        min: z.number(),
        max: z.number(),
    }),
    handler: async ({ values, label }) => {
        const sum = values.reduce((a, b) => a + b, 0)
        return {
            label,
            count: values.length,
            sum,
            average: sum / values.length,
            min: Math.min(...values),
            max: Math.max(...values),
        }
    },
})

Build the specialist agents

Each specialist is a focused LlmAgent with its own model, instructions, and tool subset.

// src/agents/specialists.ts
import { LlmAgent } from '@google/adk'
import { webSearch } from '../tools/web-search.js'
import { computeStats } from '../tools/analyze.js'

export const webResearcher = new LlmAgent({
    name: 'web_researcher',
    model: 'gemini-2.5-flash',
    description: 'Runs web searches and extracts relevant facts from results.',
    instructions: [
        'You are a research specialist. When asked for information, run web searches with targeted queries.',
        'Return structured findings: a short summary plus a list of sources with URLs.',
        'If the first query returns weak results, reformulate and try again (maximum 3 attempts).',
    ].join('\n'),
    tools: [webSearch],
})

export const dataAnalyst = new LlmAgent({
    name: 'data_analyst',
    model: 'gemini-2.5-flash',
    description: 'Analyzes raw findings and computes aggregations, patterns, or comparisons.',
    instructions: [
        'You are a data analysis specialist. You receive raw research findings and extract quantitative insights.',
        'Use the compute_stats tool for any numeric aggregation.',
        'Identify patterns, outliers, and relationships. Return a structured analysis — not prose.',
    ].join('\n'),
    tools: [computeStats],
})

export const reportWriter = new LlmAgent({
    name: 'report_writer',
    model: 'gemini-2.5-pro',
    description: 'Writes polished markdown reports with citations from analyzed findings.',
    instructions: [
        'You are a technical writer. Compose a clear, concise markdown report.',
        'Always cite sources inline using numbered footnotes [1], [2], etc.',
        'Structure: short executive summary, then detailed findings, then a sources section.',
        'Do not invent facts. If data is missing, say so explicitly.',
    ].join('\n'),
    // No tools — the writer works from context the supervisor passes in.
    tools: [],
})

Two things worth noting. First, Flash for research and analysis, Pro for writing — this is a deliberate cost/quality split, since writing is where quality hurts most and retrieval is where cost piles up. Second, description matters: the supervisor reads sub-agent descriptions to pick who to delegate to. Vague descriptions produce bad routing.

Compose the supervisor

This is where ADK earns its keep. Instead of a router prompt with if/else over string matching, you declare sub-agents directly.

// src/agents/supervisor.ts
import { LlmAgent } from '@google/adk'
import { webResearcher, dataAnalyst, reportWriter } from './specialists.js'

export const researchSupervisor = new LlmAgent({
    name: 'research_supervisor',
    model: 'gemini-2.5-pro',
    description: 'Coordinates a team of research specialists to answer complex questions.',
    instructions: [
        'You coordinate a research team. Break the user question into steps.',
        'Typical flow: delegate to web_researcher for facts, then data_analyst for quantitative analysis, then report_writer for the final output.',
        'Delegate by calling the transfer_to_agent tool with the sub-agent name and a focused task description.',
        'When the report_writer returns a report, return it to the user verbatim. Do not rewrite it.',
        'If any specialist fails or returns weak output, either retry once with a refined task, or explain the failure to the user.',
    ].join('\n'),
    subAgents: [webResearcher, dataAnalyst, reportWriter],
})

The subAgents array is what makes this multi-agent. ADK auto-generates a transfer_to_agent tool on the supervisor, routes the sub-agent's response back, and threads conversation state across the handoff. You don't write that glue yourself.

Run it locally

ADK ships with a Runner class for programmatic use and a CLI for quick testing.

// src/run.ts
import { Runner, InMemorySessionService } from '@google/adk'
import { researchSupervisor } from './agents/supervisor.js'

async function main() {
    const question = process.argv.slice(2).join(' ')
    if (!question) {
        console.error('Usage: tsx src/run.ts "your question"')
        process.exit(1)
    }

    const runner = new Runner({
        agent: researchSupervisor,
        sessionService: new InMemorySessionService(),
        appName: 'research-assistant',
    })

    const session = await runner.sessions.create({ userId: 'demo-user' })

    try {
        for await (const event of runner.runStream({
            sessionId: session.id,
            userId: 'demo-user',
            input: question,
        })) {
            if (event.type === 'agent_transfer') {
                console.error(`→ ${event.from} delegating to ${event.to}`)
            } else if (event.type === 'tool_call') {
                console.error(`  • ${event.agent} called ${event.toolName}`)
            } else if (event.type === 'message' && event.role === 'assistant') {
                process.stdout.write(event.textDelta ?? '')
            }
        }
        process.stdout.write('\n')
    } catch (err) {
        console.error('Agent run failed:', err)
        process.exit(1)
    }
}

main()
tsx src/run.ts "What were the top three AI funding rounds in Q1 2026 and what do they have in common?"

You'll see the delegation chain on stderr and the final report streamed to stdout. The agent_transfer events are what makes this debuggable — you can watch the supervisor decide. If you're coming from a "one giant prompt" agent, this visibility alone is worth the switch.

Gotchas I hit

A few things that tripped me up:

  • Sub-agent descriptions are load-bearing. My first supervisor kept sending analysis tasks to the web researcher. The fix was rewriting description to be action-focused ("Analyzes raw findings...") instead of capability-focused ("A data analyst.").
  • Tool output schemas must match reality. If your handler returns extra fields not in the Zod schema, ADK strips them silently. Keep the schema and the handler in sync.
  • Streaming text comes through event.type === 'message' with role: 'assistant'. Tool calls and transfers are separate event types — don't try to parse them out of the text stream.
  • Don't nest supervisors more than two levels deep. I tried a supervisor-of-supervisors and routing latency exploded. For most real tasks, one supervisor + specialists is plenty.

Deploy to Cloud Run

ADK has a built-in HTTP server and a deploy command. For a real service:

// src/server.ts
import { createAdkServer } from '@google/adk/server'
import { researchSupervisor } from './agents/supervisor.js'

const server = createAdkServer({
    agent: researchSupervisor,
    appName: 'research-assistant',
    cors: { origin: process.env.ALLOWED_ORIGIN ?? '*' },
})

server.listen(Number(process.env.PORT ?? 8080), () => {
    console.log(`ADK server running on port ${process.env.PORT ?? 8080}`)
})

Deploy:

gcloud run deploy research-assistant \
  --source . \
  --region us-central1 \
  --allow-unauthenticated \
  --set-env-vars "GEMINI_API_KEY=${GEMINI_API_KEY},BRAVE_SEARCH_API_KEY=${BRAVE_SEARCH_API_KEY}"

Cloud Run autoscales to zero, so the cold start on the first request is about 2–3 seconds — fine for a backend agent, annoying for a chat UI. For user-facing apps, keep at least one instance warm (--min-instances=1).

How it compares to Vercel AI SDK

Briefly, because the prompt promised it:

  • Vercel AI SDK is lower-level. You write the agent loop, you pick when to call tools. Better for full control and streaming UI integration.
  • Google ADK is higher-level. You declare agents and sub-agents, it handles the loop and the handoffs. Better for structured multi-agent systems and when you want to deploy to Google Cloud anyway.

If your app is a chat UI powered by a single tool-using agent, Vercel AI SDK is probably still the right call. If you're building a backend research system with 3–5 specialist agents, ADK will save you real code.

What's next

A multi-agent system is only as good as its observability. The next post in this series covers adding LLM tracing with Langfuse to TypeScript AI apps so you can see exactly what each sub-agent did, how long it took, and what it cost — essential for debugging the kind of multi-step delegation we just built.

Share:
VA

Vadim Alakhverdov

Software developer writing about JavaScript, web development, and developer tools.

Related Posts