Build a Skill-Powered AI Agent

Build a LangGraph agent that loads skill files to reshape its behavior dynamically.

Introduction

30 Second Summary

Most AI assistants respond the same way every time, no matter what you ask. Imagine swapping a single file and watching the same assistant go from writing paragraphs of over-engineered code to firing back a confident one-liner.

In this project, you will build a command-line AI agent powered by Gemini 2.5 Flash that loads markdown "skill" files and completely changes its behavior based on whichever skill is active.

What You'll Build

Run the agent with a coding question, and it responds with a minimal, opinionated answer shaped by the loaded skill. Swap the skill file and the whole personality changes, without touching a single line of code.

By the end of this project, you'll have:

  • A working LangGraph agent where you ask a coding question and get a response shaped by whichever skill file is loaded.
  • Hands-on experience with three core workflow patterns: sequential execution, conditional branching that skips the skill for non-coding questions, and a tool-calling loop where the agent analyzes code before responding.
  • A portable Ponytail-inspired skill system you can extend with your own custom skills to reshape the agent's behavior without editing code.
  • Secret Mission: Add mode switching so the agent supports lite, full, and ultra intensity levels from the loaded skill.

Are there any prerequisites?

You should be comfortable reading TypeScript and have Node.js installed on your machine.

You will also need a Google Gemini API key. The project runs entirely within the free tier, so there is no cost.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters. A skill-powered agent loads its behavior from external files, so you can completely change how it responds without touching any code.

Set Up the Project

To build a skill-powered AI agent, you need the right foundation in place. LangGraph provides the graph framework, the Gemini integration handles the LLM calls, and a TypeScript runner lets you execute everything from the command line.

In this step, you will install all the dependencies, create a skill file for the agent to load, and run a smoke test to prove the wiring works.

In this step, get ready to:
  • Initialize the project and install LangGraph with the Gemini provider.
  • Configure the API key and create the Ponytail skill file.
  • Create a smoke test to confirm everything is wired up.
Initialize the project and install dependencies

Every Node.js project starts with a directory and a package.json. You will create both, then install the packages that power the agent.

  • Open your terminal.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the project folder and move into it by running these commands:
mkdir skill-agent
cd skill-agent
  • Initialize a new Node.js project by running:
npm init -y

You should see a confirmation that package.json was created.

  • Install the project dependencies by running:
npm install @langchain/langgraph @langchain/google-genai @langchain/core zod dotenv
  • Install the dev dependencies by running:
npm install -D tsx typescript

You should see output confirming packages were added.

What did these installs do?

You installed five runtime packages and two dev tools.

  • @langchain/langgraph is the graph framework that orchestrates nodes and edges.
  • @langchain/google-genai connects LangGraph to the Gemini API.
  • zod defines schemas for tool inputs and state fields.
  • dotenv loads your API key from a .env file.
  • tsx runs TypeScript directly without a separate compile step.

Now you need two configuration files: one for TypeScript and one edit to package.json.

  • Open the skill-agent folder in VS Code by clicking File in the top menu bar.
  • Click Open Folder.
  • Select the skill-agent folder on your Desktop, then click Open.
  • Create a new file called tsconfig.json in the root of your project by right-clicking in the Explorer sidebar and selecting New File.
  • Paste the following configuration into tsconfig.json:
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "bundler",
    "esModuleInterop": true,
    "strict": true,
    "skipLibCheck": true
  },
  "include": ["*.ts"]
}

What does this config do?

  • moduleResolution: "bundler" lets TypeScript resolve ES module imports the way modern tools expect.
  • target and module set to ES2022 enable top-level await and modern syntax.
  • include: ["*.ts"] tells TypeScript to check all .ts files in the project root.

Next, update package.json so Node.js treats your files as ES modules.

  • Open package.json in VS Code.
  • Find these lines near the top:
"main": "index.js",
"scripts": {
  "test": "echo \"Error: no test specified\" && exit 1"
},
  • Replace them with the following:
"type": "module",
"scripts": {
  "start": "tsx agent.ts"
},
  • Also delete the "keywords", "author", and "license" fields so your file stays clean.
  • Save package.json.

Why add type module?

Setting "type": "module" tells Node.js to use ES module syntax (import/export) instead of CommonJS (require). LangGraph and the Gemini package both use ES modules.

  • Confirm your files exist by running this in the terminal:
ls

You should see node_modules, package.json, package-lock.json, and tsconfig.json listed.

Missing tsconfig.json in the output?

  • Make sure you saved the file in VS Code. An unsaved file only exists in the editor buffer, not on disk.
  • Confirm the file is at the root of skill-agent, not nested inside another folder.

Still stuck? Help me troubleshoot why tsconfig.json isn't showing up when I run ls in my skill-agent directory.

Configure the API key and create the skill file

The agent needs a Gemini API key to call the LLM, and a skill file to load at runtime.

  • Create a new file called .env in the root of skill-agent.
  • Paste the following into .env:
GOOGLE_API_KEY=your-api-key-here
  • Replace your-api-key-here with your actual Gemini API key.
  • Save .env.

Keep your key private

Never commit .env to version control. If you add a .gitignore later, include .env in it.

Now create the skill file that the agent will load to change its behavior.

  • Create a skills folder by running:
mkdir skills
  • Confirm the folder exists by running:
ls

You should see skills listed alongside your other project files.

  • Create a new file called ponytail.md inside the skills folder.
  • Paste the following into skills/ponytail.md:
# Ponytail - lazy senior dev mode

You are a lazy senior developer. Lazy means efficient, not careless. The best code is the code never written.

Before writing any code, stop at the first rung that holds:

1. Does this need to be built at all? (YAGNI)
2. Does it already exist in this codebase? Reuse the helper, util, or pattern that's already here.
3. Does the standard library already do this? Use it.
4. Does a native platform feature cover it? Use it.
5. Does an already-installed dependency solve it? Use it.
6. Can this be one line? Make it one line.
7. Only then: write the minimum code that works.

The ladder runs after you understand the problem, not instead of it.

Rules:
- No abstractions that were not explicitly requested.
- No new dependency if it can be avoided.
- No boilerplate nobody asked for.
- Deletion over addition. Boring over clever. Fewest files possible.
- Shortest working diff wins.

What is the lazy ladder?

The Ponytail skill is inspired by the open-source Ponytail project. It gives the AI a decision ladder: before writing code, check whether the problem is already solved by something simpler.

The ladder runs top to bottom. The agent stops at the first rung that applies. This produces minimal, focused output instead of verbose boilerplate.

  • Now add the intensity levels and exclusion rules below the content you just pasted in skills/ponytail.md:

## Levels

| Level | What changes |
|-------|-------------|
| lite | Build what is asked, but name the lazier alternative in one line. User picks. |
| full | The ladder enforced. Stdlib and native first. Shortest diff, shortest explanation. Default. |
| ultra | YAGNI extremist. Deletion before addition. Ship the one-liner and challenge the rest of the requirement in the same breath. |

## When NOT to be lazy

Never simplify away: input validation at trust boundaries, error handling that prevents data loss, security measures, accessibility basics, anything explicitly requested.

Do NOT use for non-coding requests (general knowledge, prose, translation, summaries, recipes).

What do the levels do?

  • lite builds what you asked but suggests a simpler alternative.
  • full (default) enforces the entire lazy ladder. Shortest diff wins.
  • ultra challenges whether the task should exist at all.

The exclusion rule at the bottom tells the agent to skip the skill entirely for non-coding requests. You will wire this into the graph as conditional routing in a later step.

  • Save skills/ponytail.md.

✔️ Awesome, I've got everything!

Great. Double check you have saved skills/ponytail.md and move on.

ⓧ I'd like to double check the full code

Your skills/ponytail.md should contain the two sections you pasted above, in order:

  • The lazy ladder and rules (starting with # Ponytail - lazy senior dev mode and ending with Shortest working diff wins.).
  • The intensity levels table and the "When NOT to be lazy" exclusion section (starting with ## Levels and ending with the non-coding exclusion line).

Scroll up to confirm both sections are present in your file.

Can't find the skills folder in VS Code?

  • If the Explorer sidebar is hidden, press Cmd+Shift+E (macOS) or Ctrl+Shift+E (Windows) to reveal it.
  • Right-click the skills folder and select New File, then type ponytail.md.

Need more help? Help me create the ponytail.md file inside the skills folder in VS Code.

Create a smoke test file

Before building the real agent, confirm that all packages import correctly with a minimal test file.

  • Create a new file called agent.ts in the root of skill-agent.
  • Paste the following into agent.ts:
import "dotenv/config";
import { StateGraph } from "@langchain/langgraph";
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";

console.log("Hello from LangGraph!");

What does this code do?

  • import "dotenv/config" loads your .env file so GOOGLE_API_KEY is available as an environment variable.
  • The StateGraph and ChatGoogleGenerativeAI imports prove that your packages installed correctly. If either fails, you will see an error.
  • console.log("Hello from LangGraph!") gives you a clear success signal.
  • Save agent.ts.

✔️ Awesome, I've got everything!

Great. Double check you have saved agent.ts and continue below.

ⓧ I'd like to double check the full code

import "dotenv/config";
import { StateGraph } from "@langchain/langgraph";
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";

console.log("Hello from LangGraph!");

Before you run this, what do you expect to see if everything installed correctly?

  • Run the smoke test by executing:
npx tsx agent.ts

You should see Hello from LangGraph! printed to the terminal.

Seeing a module or import error?

  • Check that package.json contains "type": "module". Without it, Node.js tries to use CommonJS and fails on import statements.
  • Make sure tsconfig.json has "moduleResolution": "bundler". Other values can cause unresolved import paths.
  • Run npm list and check that @langchain/langgraph and @langchain/google-genai appear in the output.

Still stuck? Help me debug why npx tsx agent.ts is failing with an import error in my LangGraph project.

Your environment is ready and every package imports cleanly. Next up, you will build your first LangGraph workflow and watch Gemini respond to a coding question.

Build a Sequential Workflow

Your environment is ready and the smoke test passed. Now it's time to build something real.

The simplest LangGraph workflow is a straight line: START to a node to END. In this step, you'll wire that single-node graph so the agent passes your input to Gemini and prints whatever comes back.

The response will be verbose and unguided. That's intentional. This is your "before" baseline, the thing you'll dramatically improve when you inject a skill in the next step.

In this step, get ready to:
  • Define the agent's state schema with a messages array and a skill content field.
  • Create a node that calls Gemini and wire it into a sequential graph.
  • Run the agent and observe its unguided, verbose response.
Define the agent state

Every LangGraph graph needs a state schema. The state is a typed object that flows through each node, carrying data from one step to the next.

  • In VS Code, open agent.ts and replace its entire contents with the following imports and state definition:
import "dotenv/config";
import * as z from "zod";
import {
  StateGraph,
  StateSchema,
  MessagesValue,
  START,
  END,
  type GraphNode,
} from "@langchain/langgraph";
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
import { HumanMessage } from "@langchain/core/messages";

// --- State ---

const AgentState = new StateSchema({
  messages: MessagesValue,
  skillContent: z.string().default(""),
});

What does this code do?

StateSchema defines the shape of data that flows through your graph. It uses zod under the hood for type-safe validation.

MessagesValue is a special LangGraph type for conversation history. It has a built-in reducer that appends new messages instead of overwriting old ones.

The skillContent field is an empty string for now. You'll fill it in the next step when you load a skill file from disk.

  • Save agent.ts.
  • Confirm your imports are valid by running:
npx tsx agent.ts

You should see no output and no errors. The file defines the state but doesn't execute anything yet, so a clean exit means your imports resolved correctly.

Seeing a module not found error?

  • Check that your import paths match exactly. @langchain/langgraph and @langchain/google-genai are the correct package names.
  • Make sure you ran npm install in Step 1. If you're unsure, run it again.
  • Confirm your package.json has "type": "module" set.

Help me debug my LangGraph import error

Create the LLM call node and wire the graph

A node in LangGraph is an async function that receives the current state and returns a partial update. Your first node will call Gemini with the conversation messages and return the response.

  • In agent.ts, add the model configuration and node function below the state definition:
// --- Model ---

const model = new ChatGoogleGenerativeAI({
  model: "gemini-2.5-flash",
  apiKey: process.env.GOOGLE_API_KEY,
});

// --- Nodes ---

const callModel: GraphNode<typeof AgentState> = async (state) => {
  const response = await model.invoke(state.messages);
  return { messages: [response] };
};

What does this code do?

  • ChatGoogleGenerativeAI creates a connection to the Gemini API using the key from your .env file.
  • The callModel function is typed as GraphNode<typeof AgentState>. This tells TypeScript it receives the full state and must return a partial state update.
  • Returning { messages: [response] } appends Gemini's response to the conversation history (thanks to the built-in reducer in MessagesValue).
  • Now add the graph wiring and run section below the node. This connects everything into a sequential workflow and executes it:
// --- Graph ---

const graph = new StateGraph(AgentState)
  .addNode("callModel", callModel)
  .addEdge(START, "callModel")
  .addEdge("callModel", END)
  .compile();

// --- Run ---

const query = process.argv[2] ?? "Write a function to reverse a string in TypeScript";

console.log(`\nQuestion: ${query}\n`);
console.log("---");

const result = await graph.invoke({
  messages: [new HumanMessage(query)],
});

const finalMessage = result.messages.at(-1);
console.log(`\nAgent: ${finalMessage?.content}\n`);

What does this code do?

  • StateGraph(AgentState) creates the graph builder using your state schema.
  • addEdge(START, "callModel") and addEdge("callModel", END) wire the sequential flow: START -> callModel -> END.
  • .compile() finalizes the graph into a runnable that you can invoke with initial state.
  • process.argv[2] lets you pass a custom question from the command line. If you don't, it defaults to a string-reverse prompt.
  • Save agent.ts.

Before you run this, what kind of response do you expect from Gemini when asked to write a function? Do you think it will be short and minimal, or long and detailed?

  • Run the agent by executing:
npx tsx agent.ts

You should see a lengthy, detailed response from Gemini. It will likely include a full function with comments, explanations of how it works, and possibly alternative approaches. This is unguided output with no skill shaping it.

Why is the response so verbose?

Without any system prompt or constraints, Gemini defaults to being thorough. It explains everything, adds examples, and suggests edge cases. This is your "before" state. In the next step, you'll load the Ponytail skill and watch this same question produce a minimal one-liner instead.

Seeing an API key error?

  • Open your .env file and confirm GOOGLE_API_KEY is set to your actual key (not the placeholder text).
  • Make sure there are no spaces around the = sign in your .env file.
  • Verify your API key is active by checking your Google AI Studio dashboard.

Help me fix my Gemini API key error

✔️ Awesome, I've got everything!

Great. Double check you've saved agent.ts and move on to the next step.

ⓧ I'd like to double check the full code

Your agent.ts should contain these sections in order. Scroll up and confirm each is present:

  • Imports block (dotenv/config, zod, LangGraph exports, ChatGoogleGenerativeAI, HumanMessage).
  • State section (AgentState with messages and skillContent).
  • Model section (ChatGoogleGenerativeAI configured with gemini-2.5-flash).
  • Node section (callModel function that invokes the model and returns the response).
  • Graph section (StateGraph with START -> callModel -> END, compiled).
  • Run section (reads process.argv[2], invokes the graph, and prints the final message).

You have a working sequential agent, but Gemini is giving you paragraphs of explanation for a simple string-reverse function. Next up, you'll load the Ponytail skill file and inject it as a system prompt to dramatically change that behavior.

Load a Skill and Inject It

In Step 2, you saw the agent respond to a coding question with paragraphs of explanation, multiple approaches, and verbose commentary. That's what happens when an LLM has no constraints.

Loading a skill file into the system prompt will radically change that behavior. The same question that produced a wall of text will now produce a minimal one-liner. This is where you feel why dynamic skill injection matters.

In this step, get ready to:
  • Add a loadSkill node that reads the Ponytail skill file from disk.
  • Inject the skill content as a system prompt to reshape Gemini's responses.
Add skill loading to the graph

The skills/ponytail.md file already exists on disk from Step 1. You need a new graph node that reads it and stores the content in state so other nodes can use it.

  • In agent.ts, find this import line near the top of the file:
import "dotenv/config";
  • Add this new import directly below it:
import { readFileSync } from "fs";
  • Now find this line further down in the imports:
import { HumanMessage } from "@langchain/core/messages";
  • Replace it with this to also import SystemMessage:
import { HumanMessage, SystemMessage } from "@langchain/core/messages";

With those imports in place, you can create the node that reads the skill file.

  • In the // --- Nodes --- section, add the following loadSkill node above the existing callModel node:
const loadSkill: GraphNode<typeof AgentState> = async () => {
  const content = readFileSync("skills/ponytail.md", "utf-8");
  console.log("[Node] loadSkill - loaded ponytail.md");
  return { skillContent: content };
};

What does this code do?

This node reads the entire Ponytail skill file from disk and returns it as a state update. LangGraph merges the returned object into the graph's state, so after this node runs, state.skillContent holds the full markdown text.

The console.log is a breadcrumb so you can see the node fire when you run the agent.

The node exists but the graph doesn't use it yet. Time to rewire the edges.

  • In the // --- Graph --- section, find this code:
const graph = new StateGraph(AgentState)
  .addNode("callModel", callModel)
  .addEdge(START, "callModel")
  .addEdge("callModel", END)
  .compile();
  • Replace it with this to insert loadSkill between START and callModel:
const graph = new StateGraph(AgentState)
  .addNode("loadSkill", loadSkill)
  .addNode("callModel", callModel)
  .addEdge(START, "loadSkill")
  .addEdge("loadSkill", "callModel")
  .addEdge("callModel", END)
  .compile();

What changed in the graph?

The flow is now START -> loadSkill -> callModel -> END. Every time the graph runs, it reads the skill file first, then passes the enriched state (with skillContent filled in) to the LLM call.

  • Save agent.ts.
  • Run the agent to confirm the new node fires:
npx tsx agent.ts

You should see [Node] loadSkill - loaded ponytail.md printed before the agent's response. The response itself is still verbose because callModel doesn't use the skill content yet.

Seeing a file not found error?

  • Make sure skills/ponytail.md exists. The file path is relative to where you run the command, so run from inside the skill-agent folder.
  • Check for typos in the path string. It should be exactly "skills/ponytail.md".
  • Why can't my loadSkill node find the skill file?
Inject the skill as a system prompt

The skill text is in state, but callModel still ignores it. You need to prepend it as a SystemMessage so Gemini treats it as behavioral instructions.

  • In agent.ts, find the current callModel node:
const callModel: GraphNode<typeof AgentState> = async (state) => {
  const response = await model.invoke(state.messages);
  return { messages: [response] };
};
  • Replace the entire callModel function with this version that checks for skill content and prepends it:
const callModel: GraphNode<typeof AgentState> = async (state) => {
  // Build the message array, optionally prepending the skill
  const messages = [];
  if (state.skillContent) {
    messages.push(new SystemMessage(state.skillContent));
  }
  messages.push(...state.messages);
  const response = await model.invoke(messages);
  return { messages: [response] };
};

What does this code do?

Instead of passing state.messages directly to Gemini, the node first builds a new array. If a skill is loaded, its entire text becomes a SystemMessage at the front of the conversation.

System messages act as persistent instructions that shape every response the LLM produces. The Ponytail skill tells Gemini to be a lazy senior dev, so its answers will now be minimal and direct.

  • Save agent.ts.

Before you run this, think back to Step 2. The same question produced paragraphs of explanation. What do you think the output looks like now that the Ponytail skill is active?

  • Run the agent with the same default coding question:
npx tsx agent.ts

The response should be dramatically shorter. Instead of paragraphs explaining multiple approaches, you should see a few lines at most. The Ponytail skill's "shortest working diff wins" rule kicks in and Gemini gives you the minimal answer.

Why is the difference so dramatic?

LLMs are highly sensitive to system prompts. The Ponytail skill contains explicit rules like "no boilerplate nobody asked for" and "shortest working diff wins." These constraints override Gemini's default behavior of being thorough and verbose.

This is the core insight of skill injection. You changed the agent's entire personality by loading a text file. No code changes required.

Response still looks verbose?

  • Check that your if (state.skillContent) block is present. Without it, the skill text never reaches Gemini.
  • Make sure the graph still wires through loadSkill first. Look for [Node] loadSkill - loaded ponytail.md in the terminal output.
  • Confirm the SystemMessage import is present in your import line from @langchain/core/messages.
  • Why is my agent still giving verbose responses after loading the skill?

✔️ Awesome, I've got everything!

Great. Make sure you've saved agent.ts and move on to the next step.

ⓧ I'd like to double check the full code

Your agent.ts should now contain these sections in order. Scroll up and confirm each is present:

  • Imports: dotenv/config, readFileSync from fs, zod, LangGraph exports, ChatGoogleGenerativeAI, and HumanMessage + SystemMessage.
  • State: AgentState with messages and skillContent.
  • Model: ChatGoogleGenerativeAI with gemini-2.5-flash.
  • Nodes: loadSkill (reads file, returns skillContent) and callModel (prepends SystemMessage if skill loaded).
  • Graph: START -> loadSkill -> callModel -> END.
  • Run section: reads query from process.argv[2], invokes graph, prints final message.

Your agent now loads a skill file and uses it to reshape every response. But there's a problem. Try asking it "what is the capital of France?" and the skill still fires. The Ponytail rules explicitly say "Do NOT use for non-coding requests." Next up, you'll add conditional branching so the agent decides per-request whether the skill applies.

Add Conditional Branching

In Step 3, you loaded the Ponytail skill and saw how it reshapes the agent's response from verbose paragraphs into concise one-liners.

There is a problem with the current setup. The Ponytail skill file explicitly says "Do NOT use for non-coding requests." But right now, your agent applies the skill to every single question, whether it's about writing code or asking what the capital of France is. You need the agent to decide per-request whether the skill applies. This is LangGraph's conditional branching workflow pattern.

In this step, get ready to:
  • Add a classification node that categorizes each user message as "coding" or "general."
  • Create a conditional edge router that sends coding questions through the skill and general questions around it.
  • Test both paths and confirm routing decisions are logged to the terminal.
See the problem in action

Before you fix anything, see the problem first-hand. What happens if you ask the agent a general knowledge question right now?

  • Run the agent with a non-coding question:
npx tsx agent.ts "what is the capital of France?"

You will see a response that's been filtered through the Ponytail skill. The agent might refuse to answer in full, give you a terse non-answer, or tell you this is not a coding task. That is the Ponytail skill interfering where it should not.

Why is this a problem?

The skill itself warns against this. Applying a "lazy senior dev" persona to a geography question makes the agent unreliable for anything except code. Conditional routing fixes this by letting the graph decide which path to take before it reaches the LLM.

Add a classification node and conditional edges

You need three new pieces: a node that classifies the input, a second LLM-call node for non-coding questions, and a router function that decides which one to use. Start by updating your imports and state to support the new routing type.

  • In agent.ts, find this import block at the top of the file:
import {
  StateGraph,
  StateSchema,
  MessagesValue,
  START,
  END,
  type GraphNode,
} from "@langchain/langgraph";
  • Add type ConditionalEdgeRouter to the import so it looks like this:
import {
  StateGraph,
  StateSchema,
  MessagesValue,
  START,
  END,
  type GraphNode,
  type ConditionalEdgeRouter,
} from "@langchain/langgraph";
  • Next, find the AgentState definition:
const AgentState = new StateSchema({
  messages: MessagesValue,
  skillContent: z.string().default(""),
});
  • Add a taskType field below skillContent, so the state looks like this:
const AgentState = new StateSchema({
  messages: MessagesValue,
  skillContent: z.string().default(""),
  taskType: z.string().default("coding"),
});

Why default to coding?

Defaulting taskType to "coding" means the skill applies unless the classifier explicitly says otherwise. This is a safe default because the Ponytail skill is designed for coding tasks, and applying it to a true coding question is never wrong.

Now add the classification node. This function sends the user's message to Gemini with a focused system prompt asking it to output a single word: "coding" or "general."

  • In agent.ts, add the classifyInput node after the loadSkill node:
const classifyInput: GraphNode<typeof AgentState> = async (state) => {
  const lastMessage = state.messages.at(-1);
  const text = lastMessage?.content?.toString() ?? "";

  const classificationResponse = await model.invoke([
    new SystemMessage(
      "Classify the following user message as either 'coding' or 'general'. " +
        "Respond with ONLY the single word 'coding' or 'general'. " +
        "Coding includes: writing code, reviewing code, debugging, refactoring, " +
        "choosing libraries, designing APIs, or any programming-related task."
    ),
    new HumanMessage(text),
  ]);

  const classification = classificationResponse.content
    ?.toString()
    .trim()
    .toLowerCase();

  const taskType = classification === "general" ? "general" : "coding";
  console.log(`[Node] classifyInput - classified as: ${taskType}`);
  return { taskType };
};

What does this code do?

  • It grabs the last message from state (the user's question) and extracts the text.
  • It sends a separate LLM call with a strict system prompt asking for a single-word classification.
  • If the response is exactly "general", it stores that. Otherwise it defaults to "coding" (the safe default).
  • The console.log tells you which path was chosen every time you run the agent.

Now add the plain model node and the router. The callModelPlain node calls Gemini without any skill system prompt. The routeByTaskType function inspects the state and returns the name of whichever node should run next.

  • Add a console.log to the existing callModel node so you can see which path fires. Find the callModel function and add the log as the first line inside it:
const callModel: GraphNode<typeof AgentState> = async (state) => {
  console.log("[Node] callModel - skill applied");
  const messages = [];
  if (state.skillContent) {
    messages.push(new SystemMessage(state.skillContent));
  }
  messages.push(...state.messages);
  const response = await model.invoke(messages);
  return { messages: [response] };
};
  • Below the updated callModel node, add the callModelPlain node and the routing function:
const callModelPlain: GraphNode<typeof AgentState> = async (state) => {
  console.log("[Node] callModelPlain - no skill");
  const response = await model.invoke(state.messages);
  return { messages: [response] };
};

What does callModelPlain do?

This is the "bypass" path. It sends the user's messages directly to Gemini with no system prompt. General knowledge questions get a full, unrestricted answer because the Ponytail skill is never injected.

Restructure the graph with branching

All the pieces exist. Now replace the linear graph wiring with the branching version that uses addConditionalEdges.

  • Find the // --- Graph --- section in agent.ts and replace the entire graph definition with:
// --- Graph ---

const graph = new StateGraph(AgentState)
  .addNode("loadSkill", loadSkill)
  .addNode("classifyInput", classifyInput)
  .addNode("callModel", callModel)
  .addNode("callModelPlain", callModelPlain)
  .addEdge(START, "loadSkill")
  .addEdge("loadSkill", "classifyInput")
  .addConditionalEdges("classifyInput", routeByTaskType)
  .addEdge("callModel", END)
  .addEdge("callModelPlain", END)
  .compile();

What changed in the graph?

  • Two new nodes registered: classifyInput and callModelPlain.
  • After loadSkill, the graph flows to classifyInput (a fixed edge).
  • After classifyInput, the graph hits a conditional edge. LangGraph calls routeByTaskType and follows whichever node name it returns.
  • Both callModel and callModelPlain connect to END, so the graph terminates after either path.
  • Save agent.ts.
Test both paths

Time to see the branching in action. You will run two queries and watch the terminal logs to confirm each one takes a different path.

  • Before you run, predict: what will the agent print for a general knowledge question? Which node name will appear in the logs?
  • Run the agent with a general knowledge question:
npx tsx agent.ts "what is the capital of France?"

In the terminal output, you should see the log line [Node] classifyInput - classified as: general followed by [Node] callModelPlain - no skill. The answer will be a full, normal response about Paris.

  • Now run with a coding question:
npx tsx agent.ts "write a debounce function in TypeScript"

This time you should see [Node] classifyInput - classified as: coding followed by [Node] callModel - skill applied. The response will be minimal and skill-guided.

Why is this LangGraph's core differentiator?

A plain API call sends every request through the same pipeline. Conditional edges let the graph itself decide the path at runtime based on the content of each request. You have turned a static pipeline into a dynamic decision-making workflow. The same pattern scales to any number of branches.

Both questions routing to callModel?

If general knowledge questions are still hitting the skill path, check that your routeByTaskType function compares state.taskType === "general" (not "coding"). Also confirm that classifyInput is wired after loadSkill using .addEdge("loadSkill", "classifyInput").

help me debug my conditional routing in LangGraph

✔️ Awesome, I've got everything!

Great. Double check you have saved agent.ts and both test queries produced different routing logs.

ⓧ I'd like to double check the full code

Your agent.ts has grown across several edits in this step. Scroll through and confirm you have these sections in order:

  • The updated imports (including type ConditionalEdgeRouter).
  • The AgentState with taskType field.
  • The model definition.
  • Four nodes in order: loadSkill, classifyInput, callModel (with console.log), callModelPlain.
  • The routeByTaskType router under // --- Routing ---.
  • The graph using .addConditionalEdges("classifyInput", routeByTaskType).
  • The run section (unchanged from before).

Your agent now makes decisions. It reads the question, classifies it, and routes to the right path without you lifting a finger. Next up, you will give the agent tools it can call in a loop, turning it from a single-shot responder into a genuine reasoning agent.

Add Tool Calling with a Loop

Your agent can classify questions and apply the Ponytail skill, but it can only reason from Gemini's training data.

A real agent needs tools. Adding tools creates LangGraph's loop workflow: the LLM calls a tool, gets the result, then decides whether to call another tool or respond. This is the ReAct pattern that makes agents genuinely powerful.

In this step, get ready to:
  • Define two analysis tools with zod schemas and bind them to Gemini.
  • Build a tool execution node and loop it back to the model.
  • Test the full tool-calling loop with a code snippet.
Define tools and bind them to the model

Tools give the LLM the ability to call functions and use the results in its answer. You will define two tools: one that counts lines in code, and one that detects over-engineering patterns.

  • In agent.ts, find the two import lines near the top:
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
import { HumanMessage, SystemMessage } from "@langchain/core/messages";
  • Replace those two lines with the following three lines to add the tool import and the extra message types:
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
import { tool } from "@langchain/core/tools";
import {
  HumanMessage,
  SystemMessage,
  AIMessage,
  ToolMessage,
} from "@langchain/core/messages";

What are these new imports?

The tool function lets you define callable tools with typed schemas. AIMessage is the type guard for checking LLM responses. ToolMessage carries tool results back to the model.

  • Now add the first tool. Find the // --- State --- comment and paste this new // --- Tools --- section above it:
// --- Tools ---

const countLines = tool(
  (input) => {
    const lines = input.code.split("\n").length;
    return `The code has ${lines} lines.`;
  },
  {
    name: "countLines",
    description: "Count the number of lines in a code snippet.",
    schema: z.object({
      code: z.string().describe("The code snippet to count lines in."),
    }),
  }
);

What does this code do?

The tool() function takes two arguments: a function that does the work, and a config object with a name, description, and a zod schema defining the input shape.

The description tells the LLM when to use the tool. The schema gives it the exact input format to call it with.

  • Add the second tool directly below countLines, along with a tools array that holds both:
const detectPatterns = tool(
  (input) => {
    const issues: string[] = [];
    if (input.code.includes("interface") && input.code.split("interface").length === 2) {
      issues.push("Single-implementation interface detected (possible over-engineering).");
    }
    if (input.code.includes("Factory") || input.code.includes("factory")) {
      issues.push("Factory pattern detected. Does this need more than one product?");
    }
    if (input.code.split("\n").length > 50) {
      issues.push("Over 50 lines. Could this be shorter?");
    }
    if (issues.length === 0) {
      return "No obvious over-engineering patterns detected.";
    }
    return issues.join(" ");
  },
  {
    name: "detectPatterns",
    description:
      "Detect common over-engineering patterns in code (unnecessary abstractions, factories, excessive length).",
    schema: z.object({
      code: z.string().describe("The code to analyze for over-engineering patterns."),
    }),
  }
);

const tools = [countLines, detectPatterns];

What does detectPatterns look for?

This tool checks for three common over-engineering signals: single-implementation interfaces, factory patterns that only produce one product, and files longer than 50 lines. It returns a string listing the issues it found.

  • Now bind the tools to the model. Find the // --- Model --- section and locate this line:
const model = new ChatGoogleGenerativeAI({
  model: "gemini-2.5-flash",
  apiKey: process.env.GOOGLE_API_KEY,
});
  • Add this line directly below the model definition to create a version of the model that knows about your tools:
const modelWithTools = model.bindTools(tools);
  • Finally, update callModel to use modelWithTools instead of model. Find this line inside the callModel node:
  const response = await model.invoke(messages);
  • Replace model with modelWithTools so the line reads:
  const response = await modelWithTools.invoke(messages);
  • Save agent.ts and run the agent to confirm nothing broke:
npx tsx agent.ts "write a function to reverse a string in TypeScript"

You should still see a skill-guided response. The model now knows tools exist, but it cannot execute them yet. That is the next substep.

Seeing a type error or crash?

Make sure const tools = [countLines, detectPatterns]; appears after both tool definitions and before the // --- State --- section.

Check that you added import { tool } from "@langchain/core/tools"; on its own line between the google-genai import and the messages import.

help me debug my tool definition errors in LangGraph

Add the tool execution node and loop edge

When the LLM decides to call a tool, its response contains a tool_calls array instead of plain text. You need a node that executes those calls and feeds the results back. Then a conditional edge decides: did the model ask for more tools, or is it done?

  • In agent.ts, add the following code directly below the callModelPlain node definition:
const toolsByName: Record<string, typeof countLines | typeof detectPatterns> = {
  countLines,
  detectPatterns,
};

const toolNode: GraphNode<typeof AgentState> = async (state) => {
  const lastMessage = state.messages.at(-1);

  if (lastMessage == null || !AIMessage.isInstance(lastMessage)) {
    return { messages: [] };
  }

  console.log("[Node] toolNode - executing tool calls");
  const results: ToolMessage[] = [];
  for (const toolCall of lastMessage.tool_calls ?? []) {
    const selectedTool = toolsByName[toolCall.name];
    const observation = await selectedTool.invoke(toolCall);
    results.push(observation);
  }

  return { messages: results };
};

What does this code do?

  • toolsByName is a lookup map so the node can find the right tool by name.
  • AIMessage.isInstance() is a type guard. Only AI messages can contain tool_calls.
  • The node loops through each tool call, invokes it, and collects the results as ToolMessage objects that get appended to the conversation.
  • Now add the conditional edge router that decides whether to loop or end. Paste this directly below the existing routeByTaskType router in the // --- Routing --- section:
const shouldContinue: ConditionalEdgeRouter<typeof AgentState, "toolNode"> = (
  state
) => {
  const lastMessage = state.messages.at(-1);

  if (!lastMessage || !AIMessage.isInstance(lastMessage)) {
    return END;
  }

  if (lastMessage.tool_calls?.length) {
    return "toolNode";
  }

  return END;
};

What does this code do?

After callModel runs, this router checks the LLM's response. If it contains tool_calls, the graph loops to toolNode (which executes the tools and sends results back to callModel). If not, the graph ends. This loop keeps running until the LLM is satisfied.

  • Finally, update the graph wiring. Find the // --- Graph --- section and replace the entire const graph = ... block with:
const graph = new StateGraph(AgentState)
  .addNode("loadSkill", loadSkill)
  .addNode("classifyInput", classifyInput)
  .addNode("callModel", callModel)
  .addNode("callModelPlain", callModelPlain)
  .addNode("toolNode", toolNode)
  .addEdge(START, "loadSkill")
  .addEdge("loadSkill", "classifyInput")
  .addConditionalEdges("classifyInput", routeByTaskType)
  .addConditionalEdges("callModel", shouldContinue)
  .addEdge("callModelPlain", END)
  .addEdge("toolNode", "callModel")
  .compile();

What changed in the graph?

  • toolNode is registered as a new node.
  • callModel no longer has a fixed edge to END. Instead, addConditionalEdges("callModel", shouldContinue) routes to either toolNode or END.
  • addEdge("toolNode", "callModel") creates the loop: tool results feed back into the model for another pass.
  • Save agent.ts.
Test the full loop

The loop is wired. Time to trigger it. You need a prompt that makes the LLM want to use your analysis tools.

Before you run this, think: will the agent respond directly, or will it call one of your tools first?

  • Run the agent with a code snippet that invites analysis:
npx tsx agent.ts "Review this TypeScript code for over-engineering: interface IUserService { getUser(id: string): User; } class UserService implements IUserService { getUser(id: string): User { return db.users.find(id); } } class UserServiceFactory { create(): IUserService { return new UserService(); } }"

Watch the terminal output. You should see the routing log lines showing the loop in action:

  • [Node] loadSkill - loaded ponytail.md confirms the skill loaded.
  • [Node] classifyInput - classified as: coding confirms routing to the skill path.
  • [Node] callModel - skill applied shows the first LLM call (where it decides to use tools).
  • [Node] toolNode - executing tool calls confirms tools were called.
  • A second [Node] callModel - skill applied shows the loop. The model got tool results and is generating its final answer.

The final response incorporates the tool analysis into a minimal, Ponytail-guided answer. It should point out the over-engineering (single-implementation interface, unnecessary factory) in a concise style.

Agent responds without calling tools?

The LLM may choose not to use tools if it feels confident answering from training data alone. Try rephrasing your prompt to explicitly ask it to analyze the code's line count or patterns.

Also confirm you replaced model.invoke with modelWithTools.invoke inside the callModel node. Without .bindTools(), the LLM does not know tools exist.

help me debug why my LangGraph agent is not calling tools

Why build the tool node manually?

LangGraph offers a prebuilt ToolNode, but building it yourself teaches you exactly what happens inside: iterate tool_calls, invoke each tool, and return the results as messages. When something breaks in production, you will know where to look.

✔️ Awesome, I've got everything!

Double check you have saved agent.ts. Your agent now has tools, a tool node, and the loop wiring.

ⓧ I'd like to double check the full code

Your agent.ts should contain these sections in order. Scroll up and confirm each one is present:

  • Imports (dotenv, fs, zod, langgraph exports, google-genai, tools, messages).
  • // --- Tools --- section with countLines, detectPatterns, and tools array.
  • // --- State --- section with AgentState (messages, skillContent, taskType).
  • // --- Model --- section with model and modelWithTools.
  • // --- Nodes --- section with loadSkill, classifyInput, callModel (using modelWithTools), callModelPlain, toolsByName, and toolNode.
  • // --- Routing --- section with routeByTaskType and shouldContinue.
  • // --- Graph --- section with toolNode added, addConditionalEdges("callModel", shouldContinue), and addEdge("toolNode", "callModel").
  • // --- Run --- section (unchanged from before).

Your agent now has all three LangGraph workflow patterns running together: the sequential skill-loading chain, the conditional branch that skips the skill for general questions, and the tool-calling loop that lets the LLM gather information before answering. Next up, you will push this further with a secret mission that adds runtime mode switching.

Secret mission

Add Mode Switching

The Ponytail skill defines three intensity levels: lite, full, and ultra. Right now your agent always runs at full intensity. Can you add a --mode flag so the same skill produces wildly different responses depending on which level is active?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with the Gemini free tier, so there are no ongoing costs.

Resources you used:

  • The skill-agent project directory on your Desktop (contains agent.ts, skills/ponytail.md, node_modules, and config files).
  • A .env file containing your Google Gemini API key.
  • Your Google Gemini API key (remains active in your Google AI Studio account for future projects).

Keep everything running

No action needed. Choose this if you're still actively building or want to experiment with new skill files.

  • Your project stays on your Desktop, ready to run at any time.
  • Try adding more skill files to the skills/ folder (e.g., a "security auditor" skill or a "documentation writer" skill) to see how swappable skills change agent behavior.
  • Your GOOGLE_API_KEY remains active for future projects that use the Gemini API.

Pause - I'll come back to this later

Shut down any running processes to free up terminal space. Your files stay on disk.

  • If you have the agent running in a terminal, press Ctrl+C to stop it.
  • Close the terminal tab or window.
  • Your project files remain in the skill-agent folder on your Desktop whenever you want to resume.

Delete - I don't want to use this again

Remove all project files from your machine. Your Gemini API key stays active in your Google AI Studio account.

  • Delete the entire project directory by running this command in your terminal:

macOS

rm -rf ~/Desktop/skill-agent

Windows

rmdir /s /q %USERPROFILE%\Desktop\skill-agent
  • Confirm deletion by running:

macOS

ls ~/Desktop/skill-agent

Windows

dir %USERPROFILE%\Desktop\skill-agent

You should see a "No such file or directory" message confirming the folder is gone.

Nice Work!

Nice Work!

You just built a skill-powered AI agent from scratch using LangGraph and Gemini 2.5 Flash. Your agent loads portable markdown skill files, decides when to apply them, calls tools in a loop, and changes its entire personality based on a single command-line flag.

You've learned how to:

  • Build a StateGraph agent with custom state, typed nodes, and edges that orchestrate the full request lifecycle.
  • Implement three core workflow patterns: sequential (straight-line node chains), conditional branching (routing requests by classification), and looping (tool-calling ReAct cycles).
  • Load a Ponytail-format skill file from disk and inject it as a dynamic system prompt that completely reshapes agent behavior without touching code.
  • Secret Mission: Added mode switching (lite, full, ultra) so a single --mode flag changes how aggressively the skill applies at runtime.

Ready to quiz yourself?