April 21, 2026 — Matteo Merola

Skills Over Code: Teaching Agents via Markdown

Why describing CLI tools in markdown files beats writing Python adapter classes for every new agent capability.

Every agent framework I’ve used has the same onboarding story: you want the agent to check the weather, so you write a WeatherTool class. You define the input schema. You write the API call. You register the tool. You write tests. Forty minutes later, you have a working tool that wraps what is essentially a one-line curl command.

Multiply that by every capability you want the agent to have—GitHub, Jira, DNS lookups, package management, deployment scripts—and the “agent framework” becomes a full-time job writing adapter classes. The agent itself is a thin orchestration layer buried under hundreds of lines of glue code.

There is another way. Instead of writing code for each capability, you write a markdown file that describes the CLI tool the agent should use. The agent reads the description, understands the tool, and calls it through a whitelisted bash executor. No adapter class. No input schema. No registration ceremony.

The adapter class problem

Here is what a typical tool definition looks like in most agent frameworks:

weather_tool.py
class WeatherTool(BaseTool):
    name = "get_weather"
    description = "Get current weather for a city"

    class InputSchema(BaseModel):
        city: str
        units: str = "metric"

    def run(self, city: str, units: str = "metric") -> str:
        resp = requests.get(
            "https://wttr.in/{city}",
            params={"format": "j1"},
        )
        data = resp.json()
        temp = data["current_condition"][0]["temp_C"]
        desc = data["current_condition"][0]["weatherDesc"][0]["value"]
        return f"{city}: {temp}°C, {desc}"

That is 15 lines of Python to wrap what curl wttr.in/Berlin?format=j1 already does. And it is fragile—when the upstream API changes its response shape, you fix it in Python. When you want to add a new parameter, you update the Pydantic model, the method signature, and the API call. Each tool is a little maintenance surface.

The deeper problem is that this pattern scales linearly with capabilities. Twenty tools means twenty adapter files, twenty sets of tests, and twenty things to update when the framework releases a breaking change to BaseTool.

Skills: the markdown alternative

A skill is a markdown file that teaches the agent how to use an existing CLI tool. No code. The agent reads the file and learns the tool’s interface, expected outputs, and common patterns. Here is a real example—a weather skill:

skills/weather/SKILL.md
# Weather Lookup

Check current weather conditions and forecasts for any location.

## Usage

Use `curl` to query wttr.in:

```bash
# Current conditions (concise)
curl -s "wttr.in/{location}?format=%C+%t+%h+%w"

# Detailed JSON response
curl -s "wttr.in/{location}?format=j1"

# 3-day forecast
curl -s "wttr.in/{location}?format=v2"
```

## Output format

The concise format returns a single line:
`Partly cloudy +18°C 45% →11km/h`

The JSON format returns structured data with
`current_condition`, `nearest_area`, and `weather` arrays.

## Examples

- "What's the weather in Berlin?" →
  `curl -s "wttr.in/Berlin?format=%C+%t+%h+%w"`
- "Will it rain in Tokyo this week?" →
  `curl -s "wttr.in/Tokyo?format=v2"`

That is the entire skill. No Python. No schema. No registration. The agent reads this file, understands that it should use curl against wttr.in, and calls the bash executor when someone asks about weather.

How skill loading works

Skills start as markdown files in a skills/ directory—either flat files like weather.md or directories with a SKILL.md and optional bundled scripts. At startup, the system seeds these files into a SQLite database. From that point on, skills are loaded from the database, not the filesystem. This means you can edit, enable/disable, and manage skills through the admin UI without touching files on disk.

The seeding process hashes each file’s content. If the hash matches what’s already in the DB, the seed is skipped. If you edit the file on disk, the next boot picks up the change. If you edit the skill via the admin UI, the DB version takes precedence until you reset it to its seed. This gives you the best of both worlds: version-controlled files for your base skills, and a live-editable database for iteration.

The key insight is that skill content does not live permanently in the context window. Skills are indexed by name and description. The full content is pulled in only when relevant to the current conversation turn. A skill about GitHub CLI is not consuming tokens when the user is asking about the weather.

The bash executor

Skills instruct the agent to run CLI commands. Those commands execute through a whitelisted bash executor—not an open shell. The executor enforces a permission model: each command pattern is checked against a set of glob rules (ALWAYS allowed, ASK for approval, NEVER permitted). A skill that uses curl works because curl is in the default allowlist. A skill that tries to run rm -rf / gets blocked before it executes.

The executor also enforces timeouts, captures stdout/stderr separately, and returns structured results so the agent can distinguish between a command that failed and one that returned an error message as its normal output.

Installing skills from git

Skills can be installed from git repositories through the admin UI. A skill repo is just a directory with a SKILL.md and optionally some bundled helper scripts. You paste the repo URL into the skills page, and the system shallow-clones it, validates the SKILL.md, copies the content into a writable data/skills/ directory, and seeds it into the database. An .origin marker records where the skill came from so you can pull updates later.

Installed skills are marked read-only in the admin UI—you cannot edit them inline, only update from their origin. This prevents drift between your local copy and the upstream repo. If you need to customize a community skill, fork the repo and install from your fork.

The result is a lightweight distribution model for agent capabilities. No package registry, no dependency resolution, no version conflicts. A skill is a text file in a git repo. The skill author writes the markdown; users install with a URL.

Tradeoffs

This approach is not free. There are real tradeoffs worth understanding.

Flexibility vs. type safety

Adapter classes give you compile-time (or at least Pydantic-time) validation of inputs. A skill file gives you nothing—the agent might construct a malformed command. In practice, this matters less than you would expect. The bash executor catches failures, and the agent sees the error output and self-corrects. The feedback loop is fast: bad command → error message → fixed command. That said, for tools where a malformed call has side effects (sending an email, deleting a resource), the permission model is your safety net, not type checking.

CLI latency vs. native API calls

Spawning a subprocess is slower than calling a Python function. A native requests.get() takes maybe 200ms for the network round trip. Shelling out to curl adds process startup overhead—typically 5-15ms on Linux. For agent interactions where the LLM inference itself takes 1-3 seconds, the subprocess overhead is noise. If you need to call a tool thousands of times in a tight loop, write a native tool. For the 95% case of one-shot calls during conversation, a subprocess is fine.

Discoverability

With typed tool classes, your IDE shows you what is available. With skill files, you need to read the skills directory. This is a real downside for developers. It is not a downside for the agent—it sees the skill index in its context and knows exactly what is available. The discoverability problem is a human one, and it is solved by a simple ls skills/ or a UI that lists installed skills.

When to use each approach

Skills work best for capabilities that wrap existing CLI tools: git, curl, jq, gh, himalaya, docker. The tool already exists, is already tested, and already has documentation. Writing an adapter class for it is pure overhead.

Native tool classes make sense for capabilities that require complex state management, need to interact with Python objects in memory, or must call APIs where the authentication flow is complex enough that a bash one-liner will not cut it. In humux, calendar and contacts use native Python tools because the CalDAV and CardDAV protocols require stateful HTTP sessions with authentication that would be painful to express as CLI commands.

The sweet spot is a hybrid: native tools for the core platform (messaging, calendar, memory, voice) and skills for everything else. The core tools are stable and worth the maintenance cost. The long tail of capabilities—weather, search, deployment, monitoring—changes frequently and is better served by markdown files that anyone can write and share.

humux implements the skills-over-code paradigm out of the box. Try it →