Skills as plugins: a tool-use plugin system that scales past 20 tools
Why every Nova capability — calendar, CRM, vault, screener, portfolio — is a plugin package, what the contract looks like, and how a chat-time skills picker keeps tool sprawl from drowning the model.
The first thing that breaks in a tool-using agent is "give the model every tool you have." It works at five tools. It limps at fifteen. By twenty, the model is misrouting calls, the system prompt is bloated, and you're paying tokens for tools that aren't relevant to the question at hand. Nova's solution is to treat capabilities as skills, each one a self-contained plugin that registers with a central registry, ships its own tools, and can be enabled or disabled at runtime.
What a skill is
A skill is a workspace package under packages/skills/<name> that exports a single object:
// packages/skills/calendar/src/index.ts
export const skill: SkillDefinition = {
id: 'calendar',
name: 'Calendar',
description: 'Read and write to the user\'s calendar',
envSchema: z.object({
GOOGLE_CALENDAR_CLIENT_ID: z.string(),
GOOGLE_CALENDAR_CLIENT_SECRET: z.string(),
}),
tools: {
list_events: {
description: 'List upcoming calendar events',
parameters: z.object({ days: z.number().default(7) }),
execute: async ({ days }) => { /* ... */ },
},
create_event: { /* ... */ },
},
promptHints: 'Prefer list_events when the user asks about scheduling.',
};
The contract is small on purpose. A skill has an id, a description, an env schema (validated at boot), a flat map of tool definitions in the AI SDK's expected shape, and an optional promptHints string the registry stitches into the system prompt only when the skill is enabled.
The registry
At Nova boot, SkillRegistry imports every package under packages/skills/*, validates its env vars, and registers it. Skills with missing env vars don't crash — they're flagged as unavailable, and the chat agent never sees their tools. This way the same Nova binary works whether or not I've got a Cartesia key, a Finnhub key, or a Google Calendar OAuth token. Each skill self-deactivates if it can't run.
Tools from active skills are merged into a single map handed to streamText. Each tool's name is namespaced as <skill_id>.<tool_id> so two skills can both have a list tool without colliding.
The chat skills picker
The model is good at picking among a dozen tools. It is not good at picking among forty. So Nova has a chat-time picker: type / in an empty composer and a popover surfaces every available skill, two-level tree (skill → tools), fuzzy filter, click-to-insert. Picking a tool inserts the tool's description into the composer as the starting prompt, and the message that gets sent biases the model toward that specific tool.
The win: I keep two dozen tools registered, but the model usually only sees a focused subset because the user message is already shaped around one of them.
The skill that almost broke this
A few weeks ago I shipped a new screener skill with eleven tools. Suddenly chat queries like "pull up UBER" started misrouting — the model picked screener.scan_universe because the word "pull" had recently appeared in scan-related contexts and the screener's prompt hints were over-fitting. Fix: the new lookup skill (a TradingView-style symbol panel) added explicit prompt hints to distinguish itself from screener:
promptHints: `
Use lookup.pull_up_symbol for phrases like "pull up TICKER",
"show me TICKER", "look at TICKER". This is NOT a screener
tool — do not confuse with screener.scan_universe.
`
This kind of negative-space prompt tuning is a constant tax on plugin systems. You can't always rely on tool descriptions alone — you have to tell the model when not to pick a tool, too.
Vault-backed skills
The most-used skills don't talk to external APIs at all — they talk to my Obsidian vault:
- vault — read/write notes, search by content, link generation.
- crm — contacts and deal stages, stored as YAML-frontmatter markdown.
- portfolio — positions, prices (via Stooq quote endpoint), watchlist.
- budget — incomes, expenses, rules.
The pattern: each skill owns a directory under the vault. vault/CRM/, vault/Portfolio/, vault/Budget/. The skill reads and writes plain markdown. The chokidar watcher in the vault skill picks up external edits (e.g. me opening Obsidian and changing a contact note) and broadcasts an event the chat panel can listen to. So if I edit a file in Obsidian and then ask Nova about it, the answer is current — no caching layer, no sync, just the filesystem as truth.
MCP support
The SkillRegistry also speaks Model Context Protocol. An MCP server registered in nova.config.json shows up as a skill at boot, with its tools enumerated automatically. So if there's an MCP I want — Notion, Slack, GitHub — I don't have to write a skill for it. I drop a config entry and it appears.
The native skills are still where I do the work that needs custom UI affordances (the screener has a whole panel; the calendar has a peek view). MCP is the escape hatch for "I just want this one tool, please don't make me write a package."
The scaffolder
Adding a new skill is a one-liner:
pnpm nova:skill:new my-skill
That generates the package directory, package.json, a stubbed index.ts with a working echo tool, a typecheck-clean Zod schema, and the workspace edits to register it. New tool? Add a property to the skill's tools map. Restart Nova. Done.
The shape of the system means I rarely think about "where does this code go?" The answer is always: in a skill. That clarity is worth more than it sounds.
What I'd do differently
One regret: I made tool execution synchronous to the chat stream. If a tool takes 8 seconds, the model sits on that tool call for 8 seconds before generating its next token. For most tools this is fine — they return in well under a second. For the screener's full universe scan it's a problem, and I had to add streaming partial results out-of-band via IPC to keep the UI alive. If I were starting over, tools would return either values or async iterables, and the agent loop would compose them differently.
Second regret, smaller: tool descriptions are strings instead of typed prompt fragments. I want to be able to compose a tool description from sub-fragments (e.g. "always cite the source URL"), and right now that's just string concatenation. Fine for now.
Skills are the architectural feature I'm proudest of. They've turned every "Nova should also do X" conversation from a refactor question into a scaffold question. Next up: the screener — Nova's first skill that grew its own opinionated UI panel, a backtest engine, and an autonomous trade-finder that I now use every day.
Want this in real time?
Discussion happens in the Discord.