Her character6 of 8
Skills
On this page
A skill is a markdown file. Not code, not a plugin, not a tool — a playbook she can open and read when a job matches it.
The idea, from core/skill_docs.py:
"A 'skill' here is a markdown file under soul/skills/ (e.g. research.md): not code, but guidance the model pulls up when relevant. The system prompt lists the available skill titles; she calls skill_view to load a body when she needs it."
Three ship. You can add your own by dropping a file in a folder.
The three that ship
| Skill | Requires | Size | What it is for |
|---|---|---|---|
research | nothing | 10,536 chars | Being a real research agent: plan, search several angles, prefer primary sources, synthesise, and produce a cited .md report |
operating-websites | browser | 6,050 chars | Driving a real website end to end — log in, search, navigate, fill forms — with the browser_* tools |
discord-server | discord | 2,920 chars | Working in a guild: reading a conversation back, remembering people, and knowing what she cannot do there |
research
The most substantial of the three, and the one that shapes the most visible behaviour. Its sections: the dead-time rule, Phase 1 (companion), Phase 2 (work), continuing an existing investigation, and what to do when a tool fails.
Its central rule is a split between two phases, and the reason is that you are listening:
"NEVER do heavy, multi-source research inside the live voice turn. One quick search is fine; a chain of searches + reading pages is NOT — it leaves the user listening to nothing."
So the live turn does one broad search and a short spoken summary; the deep phase (3–6 sources,
cross-checking, the written report) goes to start_work in the background.
It is also the only skill in the tree with a language rule, which is why it is the one that gets loaded most carefully: the background worker sees only the goal string, so a goal written in the wrong language produces a report in that language.
operating-websites
One rule prevents most failures, and the skill leads with it:
"the browser is driven by the accessibility SNAPSHOT — you act on elements by their
ref, never by a CSS selector you remembered."
Navigate, snapshot, act on the ref, re-snapshot after the page changes. Plus sections on logging
into your own accounts, searching a site, knowing you actually succeeded (do not fabricate), saving a
photo to visual memory, and when to stop.
discord-server
Mostly about the difference between a room and a conversation:
"A guild channel is a room with people in it. Every word you write is read by everyone there, including whatever a tool hands back."
Plus: whose things are whose (your files and keys are not a stranger's to ask for), reading history back by message link or time, one lookup then answer, remembering people, and what she cannot do there.
The frontmatter
Optional YAML, fenced with --- above and below — the opposite convention from the soul file, which
uses a single --- as a separator. Getting them the wrong way round breaks both.
---
name: operating-websites
description: Drive a real website end-to-end — log in, search, navigate, fill forms.
when_to_use: any multi-step task inside a real website (Facebook, Gmail, a dashboard, a store)
requires_toolsets: [browser]
---
# Operating websites
...
| Key | Read by | Effect |
|---|---|---|
name | list_skills | The name she calls skill_view(name) with. Defaults to the filename stem |
description | list_skills | The one line shown in the prompt beside the name |
requires_toolsets | skills_for_toolsets | Work mode hides the skill unless all named toolsets are active |
when_to_use | nothing | Not parsed. It reaches her only because the whole file, frontmatter included, is what skill_view returns |
requires_toolsets accepts a list, a single string, or the nested metadata.hermes.requires_toolsets
form. An absent or empty value means "always eligible".
Frontmatter is optional. Without it, the title is the first # heading and the description is
the first non-heading paragraph. test_skill_frontmatter.py pins that fallback.
A malformed YAML block does not crash anything: the parser catches and degrades to ({}, text), so
the file still loads with a filename-derived name.
How she chooses one
She is told what exists, and she decides. There is no matcher, no embedding, no router.
In a conversation, skill_titles() puts every skill in the prompt as name — description, under
a block that pulls in two directions on purpose:
"for everyday things you already know (chat, a quick search, simple math, a basic page) just ACT — don't call skill_view as 'preparation' and don't loop on it; over-preparing makes you look like you're stalling. BUT if a skill clearly matches a non-trivial, multi-step job you're about to do … load it ONCE with skill_view(name) before you start. Load at most one, once, then act."
Note: the conversational list is not filtered by toolset. All three appear whether or not the browser and the Discord bot are running.
In work mode it is filtered, and framed harder — skills_prompt() calls it step zero:
"SKILLS (playbooks you can load) — this list is your STEP ZERO. Scan it; if one matches the job, load that ONE with skill_view(name) first and follow what it says. If none matches, there is no step zero: act straight away. Never a second skill, never a re-read."
The phrase "step zero" exists to settle a contradiction that ran for months: this block asked for the
skill while the work subsystem's BIAS TO ACTION called a skill_view a forbidden preparation step,
and the prohibition won every run. What went with it was everything a skill knows and nothing else
does — including that language rule, which is why a Spanish research request came back as an English
report.
All of the above is instruction. Nothing forces a skill to be read. The enforcement is only in the plumbing: the tool exists, the list is accurate, the body is returned.
The two tools
| Tool | What it does |
|---|---|
skill_list | Returns - name: description for every skill. Returns nothing when there are none |
skill_view(name) | Returns the skill's entire file, frontmatter included. Returns nothing when the name is unknown, which surfaces as its fail line: "I don't have a skill by that name yet." |
Both are RISK = "read", toolset skills.
skill_view slugifies the name and jails it inside the skills directory. Measured:
>>> view_skill("Research") # case-folded
<the full file>
>>> view_skill("../../etc/passwd") # slugified to nonsense, then jailed
None
Where skills live
skills_dir() answers, in order:
KOTOBA_SKILLS_DIRif set<REPO_ROOT>/soul/skillsif it is a directory- the copy packaged inside the wheel
On a clone, REPO_ROOT is the checkout, so that is soul/skills/. Add your file there.
On a wheel, REPO_ROOT is ~/.kotoba, so the answer is the packaged copy — until you create
~/.kotoba/soul/skills/.
The gotcha: the folder replaces, it does not merge
kotoba setup seeds ~/.kotoba/soul/default.md and the templates. It does not seed the skills.
The moment you create ~/.kotoba/soul/skills/ and put one file in it, the three shipped skills are
gone. Measured:
no ~/.kotoba/soul/skills -> <site-packages>/kotoba/data/soul/skills
skills: discord-server, operating-websites, research
with the folder + mine.md -> ~/.kotoba/soul/skills
skills: mine
If you want yours and theirs, copy the shipped three across first. The same is true of
KOTOBA_SKILLS_DIR: it replaces the search, it does not add to it.
The consequence for upgrades runs the other way from the soul file. On a wheel with no home skills
folder, pip install -U does give you improved skills, because they are read straight out of the
package. Create the folder and you freeze them.
Writing your own
- Choose the directory (
soul/skills/on a clone,~/.kotoba/soul/skills/on a wheel — remembering that creating it hides the shipped three). - Name the file after the skill:
deploying-my-app.md.skill_viewslugifies whatever she passes, so lowercase-with-hyphens is the safe filename. - Write frontmatter with at least
nameanddescription. The description is what she reads in the prompt when deciding, so it should say when to use this, not what it contains. - Write the body as steps. The three shipped skills are all imperative and specific: numbered core loops, explicit tool names with argument shapes, and a "what going wrong looks like" section.
- Restart is not required.
list_skills()re-reads the directory each call, and the prompt is rebuilt every turn. A new file is visible on the next thing you say.
That last point is the one difference from everything else on these pages. Skills are the only part of her character you can change without restarting her.
What makes a good one
Reading the three that ship, the pattern is consistent:
- Lead with the one rule that prevents most failures.
operating-websitesopens with refs, not selectors.researchopens with the dead-time rule. - Name the tools and their argument shapes exactly, e.g.
browser_click(target="e20", element="the Search box"). She acts on this literally. - Say what failure looks like, so she recognises it — "If a step fails with 'does not match any elements', you used a stale or invented ref".
- Say when to stop. Two of the three have a section for it.
- Keep it around ten thousand characters or less. The largest shipped skill is 10,536, and the whole file — frontmatter included — is loaded into context in one go.
What a skill cannot do
- It cannot grant a tool.
requires_toolsetshides a skill when a toolset is missing; it does not turn anything on. - It cannot change the four canned voice lines, the face map, or any cap.
- It is not automatically loaded. It is a document she may choose to open.
