Skills
A short tutorial — bundle instructions, tools, and reference material the model loads on demand, then guard the bundle with a secret and an approval.
A skill is a capability you hand the model and let it reach for when it fits the task. It bundles a one-line description the model sees every turn, the full instructions it loads only when the skill is relevant, and the tools the skill drives. Keeping the long instructions out of context until they're needed is the whole point — you can offer many skills for the cost of a few one-line descriptions.
This tutorial starts from the multi-turn chat loop and grows one program across four steps:
- Add a skill the model loads on demand.
- Bundle reference material it reads only when it needs it.
- Give it a secret it must use — without the model ever seeing it.
- Require approval before the skill acts.
Each step below shows the whole program so far — the lines it adds are highlighted.
Step 1 — Add a Skill
A skill's instructions can be long, and most turns don't need them. So the model
sees only the one-line description every turn; when it decides the skill applies,
it calls the built-in skill tool and the full instructions arrive as that
call's result. Nothing in the loop changes — disclosure is just a tool call.
Collect your skills in a SkillRegistry and give runAgent two things: the
catalog for the system prompt, and the tools — skillTool (which does the
disclosure) plus the tools the skill drives. Here a greet skill drives a shared
shell:
import {
AgentEventType,
runAgent,
SessionMemoryStore,
shellTool,
SkillRegistry,
skillTool,
} from "@open-agent-loops/core";
import type { AgentEvent, Skill } from "@open-agent-loops/core";
import { OpenAICompatibleModel } from "@open-agent-loops/core/providers/openai";
import { bunShellBackend } from "./bun-backends";
import { createInterface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
const apiKey = process.env.LLM_API_KEY;
if (!apiKey) {
console.error("Set LLM_API_KEY (see .env.example).");
process.exit(1);
}
// DeepSeek V4 tool-calls cleanly; GLM emits broken empty-key tool args.
const model = new OpenAICompatibleModel({
apiKey,
baseURL: process.env.LLM_BASE_URL ?? "https://api.featherless.ai/v1",
model: process.env.LLM_MODEL ?? "deepseek-ai/DeepSeek-V4-Flash",
thinking: "on",
});
// One shared shell tool the skill drives.
const shell = shellTool(bunShellBackend());
// A skill is pure instructions here: it tells the model how to use `shell`.
const greetSkill: Skill = {
name: "greet",
description: "Greet a person by name.",
instructions: [
"To greet <name>, run the shell command:",
' echo "Hello, <name>!"',
"Then report the output verbatim.",
].join("\n"),
};
const skills = new SkillRegistry([greetSkill]);
// The cheap catalog goes in the system prompt; the instructions stay out of it.
const system = [
"You are a friendly assistant.",
"",
"## Available skills",
skills.catalog(),
"",
"Call the `skill` tool with a skill name to load its instructions before using it.",
].join("\n");
// Render every event the loop emits, including the dimmed reasoning channel.
function render(e: AgentEvent) {
switch (e.type) {
case AgentEventType.ReasoningDelta:
process.stdout.write(`\x1b[2m${e.text}\x1b[22m`);
break;
case AgentEventType.TextDelta:
process.stdout.write(e.text);
break;
case AgentEventType.ToolStart:
console.log(`→ ${e.toolName}(${JSON.stringify(e.args)})`);
break;
case AgentEventType.ToolEnd:
console.log(`← ${e.toolName} [${e.isError ? "error" : "ok"}]: ${e.result}`);
break;
}
}
// Multi-turn: one memory + one sessionId, reused every turn.
const memory = new SessionMemoryStore();
const sessionId = "skill-tutorial";
const rl = createInterface({ input, output });
while (true) {
const prompt = (await rl.question("\nyou › ")).trim();
if (prompt === "" || prompt === "exit") break;
process.stdout.write("bot › ");
await runAgent({
model,
memory,
sessionId,
system,
prompt,
tools: [skillTool(skills), shell],
onEvent: render,
});
}
rl.close();Run it and say hello:
bun run examples/skill-tutorial/step1.ts
you › say hello to AdaThe model sees greet in the catalog, calls skill({ name: "greet" }) to load
the instructions, then runs the echo command and reports it. Because memory
and sessionId are reused, the next message continues the same conversation.
Step 2 — Bundle Reference Material on Demand
There's a third level of disclosure. A skill can carry resources — reference
docs, datasets, a form template — that load only when the model asks for them.
You hand the skill a load thunk instead of the text, so a large reference never
enters context, or even gets read off disk, until it's actually used. The
highlighted lines add a phrasebook resource and the skillResourceTool that
loads it:
import {
AgentEventType,
runAgent,
SessionMemoryStore,
shellTool,
SkillRegistry,
skillResourceTool,
skillTool,
} from "@open-agent-loops/core";
import type { AgentEvent, Skill } from "@open-agent-loops/core";
import { OpenAICompatibleModel } from "@open-agent-loops/core/providers/openai";
import { bunShellBackend } from "./bun-backends";
import { createInterface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
import { readFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const apiKey = process.env.LLM_API_KEY;
if (!apiKey) {
console.error("Set LLM_API_KEY (see .env.example).");
process.exit(1);
}
// DeepSeek V4 tool-calls cleanly; GLM emits broken empty-key tool args.
const model = new OpenAICompatibleModel({
apiKey,
baseURL: process.env.LLM_BASE_URL ?? "https://api.featherless.ai/v1",
model: process.env.LLM_MODEL ?? "deepseek-ai/DeepSeek-V4-Flash",
thinking: "on",
});
// One shared shell tool the skill drives.
const shell = shellTool(bunShellBackend());
const here = dirname(fileURLToPath(import.meta.url));
// A skill is pure instructions here: it tells the model how to use `shell`.
const greetSkill: Skill = {
name: "greet",
description: "Greet a person by name.",
instructions: [
"To greet <name>, run the shell command:",
' echo "Hello, <name>!"',
"For another language, first load the `phrasebook` resource.",
"Then report the output verbatim.",
].join("\n"),
resources: {
phrasebook: {
description: "Hello in several languages.",
load: () => readFile(join(here, "phrasebook.md"), "utf8"),
},
},
};
const skills = new SkillRegistry([greetSkill]);
// The cheap catalog goes in the system prompt; the instructions stay out of it.
const system = [
"You are a friendly assistant.",
"",
"## Available skills",
skills.catalog(),
"",
"Call the `skill` tool with a skill name to load its instructions before using it.",
].join("\n");
// Render every event the loop emits, including the dimmed reasoning channel.
function render(e: AgentEvent) {
switch (e.type) {
case AgentEventType.ReasoningDelta:
process.stdout.write(`\x1b[2m${e.text}\x1b[22m`);
break;
case AgentEventType.TextDelta:
process.stdout.write(e.text);
break;
case AgentEventType.ToolStart:
console.log(`→ ${e.toolName}(${JSON.stringify(e.args)})`);
break;
case AgentEventType.ToolEnd:
console.log(`← ${e.toolName} [${e.isError ? "error" : "ok"}]: ${e.result}`);
break;
}
}
// Multi-turn: one memory + one sessionId, reused every turn.
const memory = new SessionMemoryStore();
const sessionId = "skill-tutorial";
const rl = createInterface({ input, output });
while (true) {
const prompt = (await rl.question("\nyou › ")).trim();
if (prompt === "" || prompt === "exit") break;
process.stdout.write("bot › ");
await runAgent({
model,
memory,
sessionId,
system,
prompt,
tools: [skillTool(skills), skillResourceTool(skills), shell],
onEvent: render,
});
}
rl.close();bun run examples/skill-tutorial/step2.ts
you › say hello to Ada in FrenchNow disclosure goes three deep: catalog (L1) → skill instructions, which now
end with a manifest of the skill's resources (L2) → the resource body via
skill_resource (L3). The load() thunk runs only on that call — greet in
English and the file is never read.
Step 3 — Give the Skill a Secret
Real skills do real work, and that often needs a secret. Here the skill greets
through an access-controlled CLI that wants a token — and you don't want that
token in the model's view or the transcript. Wrap the shared shell with
withCredentials; the model only ever writes the {{placeholder}}, and the real
value is filled in for the one command that uses it and scrubbed from the result:
import {
AgentEventType,
InMemoryCredentialStore,
runAgent,
SessionMemoryStore,
shellTool,
SkillRegistry,
skillResourceTool,
skillTool,
withCredentials,
} from "@open-agent-loops/core";
import type { AgentEvent, Skill } from "@open-agent-loops/core";
import { OpenAICompatibleModel } from "@open-agent-loops/core/providers/openai";
import { bunShellBackend } from "./bun-backends";
import { createInterface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
import { readFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const apiKey = process.env.LLM_API_KEY;
if (!apiKey) {
console.error("Set LLM_API_KEY (see .env.example).");
process.exit(1);
}
// DeepSeek V4 tool-calls cleanly; GLM emits broken empty-key tool args.
const model = new OpenAICompatibleModel({
apiKey,
baseURL: process.env.LLM_BASE_URL ?? "https://api.featherless.ai/v1",
model: process.env.LLM_MODEL ?? "deepseek-ai/DeepSeek-V4-Flash",
thinking: "on",
});
const here = dirname(fileURLToPath(import.meta.url));
const binDir = join(here, "../bin");
// The secret lives in a store; the model only ever sees the placeholder name.
const credentials = new InMemoryCredentialStore({
secrets: { secret_hello_token: process.env.SECRET_HELLO_KEY ?? "s3cr3t-hello-key" },
});
// Wrap the shared shell so {{secret_hello_token}} is filled in for the one command
// that uses it, and scrubbed back out of the result.
const shell = withCredentials(shellTool(bunShellBackend({ cwd: binDir })), credentials);
// A skill is pure instructions here: it tells the model how to use `shell`.
const greetSkill: Skill = {
name: "greet",
description: "Greet a person by name.",
instructions: [
"To greet <name>, run the access-controlled CLI:",
' SECRET_HELLO_TOKEN={{secret_hello_token}} ./secret-hello "<name>"',
"For another language, first load the `phrasebook` resource.",
"Never print the token. Report the greeting verbatim.",
].join("\n"),
resources: {
phrasebook: {
description: "Hello in several languages.",
load: () => readFile(join(here, "phrasebook.md"), "utf8"),
},
},
};
const skills = new SkillRegistry([greetSkill]);
// The cheap catalog goes in the system prompt; the instructions stay out of it.
const system = [
"You are a friendly assistant.",
"",
"## Available skills",
skills.catalog(),
"",
"Call the `skill` tool with a skill name to load its instructions before using it.",
].join("\n");
// Render every event the loop emits, including the dimmed reasoning channel.
function render(e: AgentEvent) {
switch (e.type) {
case AgentEventType.ReasoningDelta:
process.stdout.write(`\x1b[2m${e.text}\x1b[22m`);
break;
case AgentEventType.TextDelta:
process.stdout.write(e.text);
break;
case AgentEventType.ToolStart:
console.log(`→ ${e.toolName}(${JSON.stringify(e.args)})`);
break;
case AgentEventType.ToolEnd:
console.log(`← ${e.toolName} [${e.isError ? "error" : "ok"}]: ${e.result}`);
break;
}
}
// Multi-turn: one memory + one sessionId, reused every turn.
const memory = new SessionMemoryStore();
const sessionId = "skill-tutorial";
const rl = createInterface({ input, output });
while (true) {
const prompt = (await rl.question("\nyou › ")).trim();
if (prompt === "" || prompt === "exit") break;
process.stdout.write("bot › ");
await runAgent({
model,
memory,
sessionId,
system,
prompt,
tools: [skillTool(skills), skillResourceTool(skills), shell],
onEvent: render,
});
}
rl.close();bun run examples/skill-tutorial/step3.ts
you › say hi to AdaWhen the model runs the command, {{secret_hello_token}} is swapped for the real
token just for that call. The secret-hello binary refuses to run without it —
so you can watch the secret do its job.
Step 4 — Require Approval
Some actions should ask you first. Approval is a separate, independent seam: the
gateToolCalls hook looks at the turn's tool calls before any run and decides
allow / deny / ask. The highlighted lines pre-approve loading a skill or a
resource, and ask before the actual shell command:
import {
AgentEventType,
ApprovalChoice,
InMemoryCredentialStore,
InMemoryPermissionStore,
permissionGate,
PermissionPolicy,
runAgent,
SessionMemoryStore,
shellTool,
SkillRegistry,
skillResourceTool,
skillTool,
withCredentials,
} from "@open-agent-loops/core";
import type { AgentEvent, ApprovalPrompter, Skill } from "@open-agent-loops/core";
import { OpenAICompatibleModel } from "@open-agent-loops/core/providers/openai";
import { bunShellBackend } from "./bun-backends";
import { createInterface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
import { readFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const apiKey = process.env.LLM_API_KEY;
if (!apiKey) {
console.error("Set LLM_API_KEY (see .env.example).");
process.exit(1);
}
// DeepSeek V4 tool-calls cleanly; GLM emits broken empty-key tool args.
const model = new OpenAICompatibleModel({
apiKey,
baseURL: process.env.LLM_BASE_URL ?? "https://api.featherless.ai/v1",
model: process.env.LLM_MODEL ?? "deepseek-ai/DeepSeek-V4-Flash",
thinking: "on",
});
const here = dirname(fileURLToPath(import.meta.url));
const binDir = join(here, "../bin");
// The secret lives in a store; the model only ever sees the placeholder name.
const credentials = new InMemoryCredentialStore({
secrets: { secret_hello_token: process.env.SECRET_HELLO_KEY ?? "s3cr3t-hello-key" },
});
// Wrap the shared shell so {{secret_hello_token}} is filled in for the one command
// that uses it, and scrubbed back out of the result.
const shell = withCredentials(shellTool(bunShellBackend({ cwd: binDir })), credentials);
// A skill is pure instructions here: it tells the model how to use `shell`.
const greetSkill: Skill = {
name: "greet",
description: "Greet a person by name.",
instructions: [
"To greet <name>, run the access-controlled CLI:",
' SECRET_HELLO_TOKEN={{secret_hello_token}} ./secret-hello "<name>"',
"For another language, first load the `phrasebook` resource.",
"Never print the token. Report the greeting verbatim.",
].join("\n"),
resources: {
phrasebook: {
description: "Hello in several languages.",
load: () => readFile(join(here, "phrasebook.md"), "utf8"),
},
},
};
const skills = new SkillRegistry([greetSkill]);
// The cheap catalog goes in the system prompt; the instructions stay out of it.
const system = [
"You are a friendly assistant.",
"",
"## Available skills",
skills.catalog(),
"",
"Call the `skill` tool with a skill name to load its instructions before using it.",
].join("\n");
// Render every event the loop emits, including the dimmed reasoning channel.
function render(e: AgentEvent) {
switch (e.type) {
case AgentEventType.ReasoningDelta:
process.stdout.write(`\x1b[2m${e.text}\x1b[22m`);
break;
case AgentEventType.TextDelta:
process.stdout.write(e.text);
break;
case AgentEventType.ToolStart:
console.log(`→ ${e.toolName}(${JSON.stringify(e.args)})`);
break;
case AgentEventType.ToolEnd:
console.log(`← ${e.toolName} [${e.isError ? "error" : "ok"}]: ${e.result}`);
break;
}
}
// Multi-turn: one memory + one sessionId, reused every turn.
const memory = new SessionMemoryStore();
const sessionId = "skill-tutorial";
const rl = createInterface({ input, output });
// Pre-approve loading a skill or a resource; ask before any shell command runs.
const permissions = new InMemoryPermissionStore({
fallback: PermissionPolicy.Ask,
rules: { skill: PermissionPolicy.Allow, skill_resource: PermissionPolicy.Allow },
});
// A terminal prompter — it shows the args verbatim, which for a credentialed
// command is the PLACEHOLDER, never the secret (the swap happens after approval).
const prompter: ApprovalPrompter = {
async ask(batch) {
const choices: ApprovalChoice[] = [];
for (const { toolCall } of batch) {
const ok = (await rl.question(`\n🔐 allow ${toolCall.function.name}? [y/N] `)).trim().toLowerCase() === "y";
choices.push(ok ? ApprovalChoice.AllowOnce : ApprovalChoice.DenyOnce);
}
return choices;
},
};
const gate = permissionGate(permissions, prompter);
while (true) {
const prompt = (await rl.question("\nyou › ")).trim();
if (prompt === "" || prompt === "exit") break;
process.stdout.write("bot › ");
await runAgent({
model,
memory,
sessionId,
system,
prompt,
tools: [skillTool(skills), skillResourceTool(skills), shell],
hooks: { gateToolCalls: gate },
onEvent: render,
});
}
rl.close();bun run examples/skill-tutorial/step4.ts
you › say hi to AdaYou'll get a prompt before the command runs. The detail that makes this safe: the
gate runs before the credential swap, so the approval shows you the placeholder
— {{secret_hello_token}}, never the real secret. Deny it and the call never
runs; it comes back as an error the model can react to.
Recap
Starting from a plain chat loop, a few lines at a time you gave the model a skill it loads on demand, reference material it reads only when needed, a secret it can use but never see, and an approval gate in front of it. The skill never had to know about any of it — you wrapped the shared tool once and gated the batch once.
Next, Permissions & Credentials goes deeper on the
policy itself — persisting "always" choices and writing the ApprovalPrompter.
The finished program is at examples/skill-tutorial/step4.ts; for the skill
seam's design, see agent-loop-core/docs/skills.md.
Code Execution
A tutorial — grow one program across four steps: run a model-written snippet in a sandbox, grant it one capability, swap the backend, then gate it behind approval.
Planning Tools
A short tutorial — give the model durable working memory with a to-do list and a scratchpad, then freeze its plan into a replayable workflow.