Build an AI Agent in Typescript / lesson 8 of 8

Build a Skills Loader

Stop rewriting tools for every new task

Play
Transcript

[00:00] At this point, our coding agent is pretty well-rounded, but we do have this situation where every time we want to create a new tool or teach it a thing, we have to write it. So I’m going to take a note from the Claude ecosystem, and we are going to teach our coding agent to use skills. So I’m going to create a new file here. We’ve got our file system, our path, our work directory from our config. We’re going to create this .Skills directory, and we’ve got our interface. We’re going to export our class of skill loader. It’s going to track skills, which will be a record of string and skill. Set up our constructor. We’ll create that directory if it doesn’t exist.

[00:55] And we’re going to loop over everything in that directory. So we’re reading the skills directory, and we’re going to check to see if first it’s a directory. So we only want to look in directories. The structure is going to be we’ve got .Skills, and inside of that we have folders for each of the skills, and inside of each of those they have to have a skill markdown file. So if not entry.Skills directory, continue, otherwise we’re going to go find that skill file, name, and then we’re looking for skill.md. So if we don’t have that, we’ll just continue on.

[01:41] Otherwise, we’re going to get the text from that guy, and we’re going to extract the, we’re going to separate the front matter from the content. So we’re going to have a this.parse front matter method here in our class. We’ll just pass it that text. So we’ll make that in a second. Now what we’re going to say is name equals, from the meta, the name field. Otherwise, that’ll just be entry.name, and we say this.Skills, name, is equal to the meta and the body. Okay. So let’s do this parse front matter guy. It’s going to return a skill. Let’s drop this in there. I didn’t make this up. I copied that from somewhere.

[02:36] It’s a way to get the elements in the, to get that section of text at the, at the top of the markdown file. So say if not match, and then we’ll just return meta is an empty object, and body is just the text. And from the match, we’ll get the raw meta and the raw body. The const meta is an empty object. And now we’ll just loop over the lines of that raw meta. And then name and description are really all we need, and they should be separated by, like, like it should say name, colon, and then the name of the thing. So that’ll be our key. And then the rest.

[03:29] So if we have a key, and the rest has a length, we’ll say meta, we’ll trim that key. And then this will equal, and then this will equal, join that guy, and we’ll trim it. And we’ll have our meta be the record of string. And then we’ll just return that. So meta and body will be our raw body. And then we’ll trim that as well. Okay. Moving on. We’re going to have a get descriptions method. The way we’re going to use that is when we initialize our system prompt, we’re going to let it know about any existing skills that might be in place. So we’ll say const entries is equal to object.entries, this.skills, and if we don’t have any of those, we’ll just return no skills available or something.

[04:42] Otherwise, we’re going to loop through those. We’ll get the name and the record. And we’ll return the name and the esmeta.description. And we’ll just join those back together with a line break. Okay. Last one. Get content. Taking the name. Return a string. The skill is from skills name. If we don’t have a skill, we’ll throw an error. Otherwise, we’ll return. Nice long string explaining the skill. Okay. So we loop through our directory, build up all the skills. We’re using this parse front matter to break that out. We’ve got this get descriptions, which we’re going to feed into our system prompt.

[05:34] And then get content for whenever we need to grab an individual skill or have it read one of those skills. I’m going to go ahead and jump over to our tools and we’re going to import our skill loader. We’re going to say skills equals new skill loader. We’re going to get our descriptions out of the skill loader. Come down here to our tools. And right after this task git. Let’s see if this guy’s doing a good enough job. I do like that. I’m going to update this to load specialized knowledge by name. Let’s see if the schema’s okay and execute. We might want to log some stuff out here, but for the most part, this looks like what we need.

[06:19] All right, so you’ll notice that I exported the skill descriptions here. We’re going to jump back over to our index. And from our tools, we’re also going to get those skill descriptions. Right after this guy, we’re going to say use load skill for specialized knowledge. And then here are the descriptions that are available to it. So I think that’s all we need. We’re going to try this out. What skills do you have? So we can see it created the skills directory, but obviously it’s going to be empty. So cool. It came back and said there’s a. Called it skill git, which I’m fine with. But originally I wanted to call that skill load.

[07:13] So let me just update skill git. Okay, so obviously it doesn’t have any skills. So we’re going to create a skill. So I’m going to create a new folder here. I’m going to call it bun testing. And that I’m going to have skill.md. And I don’t know where I got this from, but here’s a skill. It’s got a name. I don’t care about this invocable. I just care about the description and the content here. So now we’re going to see if it knows about this skill. Cool. So it knows about bun testing. So let’s come down here and I’m going to say use fun testing and create a test for, we’ll say, to do.ts.

[08:32] Okay, I saw it reading the skill, reading the to-do file, and then writing a new file. And I can see to-do.test has shown up. Got our little reminder guys coming up in the UI, but I’m not worried about that. Okay, so it claims that the tests are done. Seen some squigglies in there, but let’s just see. This is all working okay. This is just a biome thing. So I’m just going to run the test myself in a regular terminal. Hey, they’re all passing. So it took a little while, I’m not going to lie. But we just taught it a skill. And at this point, there’s no reason to think that I can’t bring in a whole bunch of different skills, however I see fit. So I’m pretty happy with this. That is a solid step forward. We can just start bringing in skills that were meant for the Claude ecosystem into our coding agent.

Stop rewriting tools for every new task. This guide shows you how to teach an AI coding agent reusable skills using markdown files, making it easier to manage specialized knowledge without constant code changes. Perfect for developers building local AI agents with TypeScript and Ollama.

Skills loader just reads a list of skills from a directory

skills.ts
import * as fs from "node:fs";
import * as path from "node:path";
import { WORKDIR } from "./config";
let _SKILLS_DIR = path.join(WORKDIR, ".skills");
export const getSKILLS_DIR = () => _SKILLS_DIR;
export const setSKILLS_DIR = (dir: string) => { _SKILLS_DIR = dir; };
interface Skill {
meta: Record<string, string>;
body: string;
}
export class SkillLoader {
private skills: Record<string, Skill> = {};
constructor() {
const skillsDir = getSKILLS_DIR();
fs.mkdirSync(skillsDir, { recursive: true });
for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const skillFile = path.join(skillsDir, entry.name, 'SKILL.md');
if (!fs.existsSync(skillFile)) continue;
const text = fs.readFileSync(skillFile, "utf8");
const { meta, body } = this.parseFrontmatter(text);
const name = meta["name"] ?? entry.name;
this.skills[name] = { meta, body };
}
}
parseFrontmatter(text: string): Skill {
const match = text.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
if (!match) return { meta: {}, body: text };
const [, rawMeta = '', rawBody = ''] = match;
const meta: Record<string, string> = {};
for (const line of rawMeta.split('\n')) {
const [k, ...v] = line.split(':');
if (k && v.length) meta[k.trim()] = v.join(':').trim();
}
return { meta, body: rawBody.trim() };
}
getDescriptions() {
const entries = Object.entries(this.skills);
if (!entries.length) return '(no skills available)';
return entries
.map(([name, s]) => {
return ` - ${name}: ${s.meta.description ?? ''}`;
})
.join('\n');
}
getContent(name: string): string {
const skill = this.skills[name];
if (!skill) return `Error: skill ${name} not found`;
return `<skill name="${name}">\n${skill.body}\n</skill>`;
}
}

Let’s add our skills loader to our Agent.

index.ts
import { generateText, isLoopFinished } from "ai";
import type { ModelMessage } from "ai";
import * as readline from "readline";
import { PARENT_TOOLS} from "./tools";
import { PARENT_TOOLS, SKILLS_DESCRIPTIONS} from "./tools";
import { api, MODEL, WORKDIR } from "./config";
import { api, CONTEXT_LIMIT, MODEL, WORKDIR } from "./config";
import { autoCompact, estimateTokens, microCompact } from "./compact";
import { AgentUI } from "./ui";

And update our system prompt

index.ts
const agentLoop = async (messages: ModelMessage[]): Promise<string> => {
let stepsSinceTodo = 0;
const { text } = await generateText({
model: api.chat(MODEL),
system: `You are a coding agent at $(WORKDIR}.
Prefer task_create/task_update/task_list for multi-step work.
Use todo for short checklists.
Use the task tool to delegate exploration or subtasks.
Use skill_get for specialized knowledge.
Skills: ${SKILL_DESCRIPTIONS}'`
messages,
tools: PARENT_TOOLS,
stopWhen: isLoopFinished(),
...

Share this post on:

Previous
Build a TUI