What is a Nemo Skill?
A skill is a Python package that gives Nemo new abilities. When a user types "send an email to John" or "summarize this PDF", Nemo's planner looks at all available tools from all skills and picks the right ones automatically. No menus, no skill selection — users just describe what they want.
Just Python
Async functions that return dicts. No SDK, no framework.
Auto-Discovered
Drop it in the skills folder. Nemo finds it instantly.
Earn Money
Publish free or set a price. You keep 70% of sales.
How it works
User types a request → Planner sees ALL tools from ALL skills → Generates a plan (2-3 LLM calls) → Executes steps → Returns results. Your skill's tools are automatically available the moment it's installed.
Quickstart — Your First Skill in 5 Minutes
Create the skill folder
# Create your skill directory
mkdir -p ~/.nemo/skills/my_weather/
Write the skill code
import httpx
from typing import Any
# 1. Tool handler — async function that does the work
async def weather_get(city: str = "", **kwargs: Any) -> dict[str, Any]:
"""Get current weather for a city."""
if not city:
return {"error": "Please specify a city"}
async with httpx.AsyncClient() as client:
resp = await client.get(
f"https://wttr.in/{city}?format=j1",
timeout=10,
)
data = resp.json()
current = data["current_condition"][0]
return {
"city": city,
"temp_c": current["temp_C"],
"description": current["weatherDesc"][0]["value"],
"humidity": current["humidity"],
}
# 2. Register tools — maps tool names to handlers
TOOLS: dict[str, Any] = {
"weather.get": weather_get,
}
# 3. Tell the LLM what tools are available
TOOL_SCHEMAS: list[dict] = [
{
"type": "function",
"function": {
"name": "weather.get",
"description": "Get current weather for a city.",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "City name (e.g. 'London', 'New York')",
},
},
"required": ["city"],
},
},
},
]
# 4. No credentials needed for this skill
CREDENTIAL_MAP: dict = {}
# 5. System prompt — context for the LLM
SYSTEM_PROMPT = "You are a weather assistant. Use weather.get to look up weather."
Add skill metadata
{
"id": "my_weather",
"name": "Weather Lookup",
"description": "Get current weather for any city.",
"version": "1.0.0",
"icon": "cloud-sun",
"category": "productivity",
"enabled": true,
"consent_defaults": {
"weather.get": "execute"
}
}
Restart Nemo and test
Restart the app. Your skill loads automatically. Now just type:
The planner will automatically find and use your weather.get tool.
File Structure
Every skill lives in its own folder with at least two files:
your_skill/
__init__.py # Required — exports TOOLS, TOOL_SCHEMAS, CREDENTIAL_MAP, SYSTEM_PROMPT
skill.json # Required — metadata, permissions, consent levels
helpers.py # Optional — helper modules
client.py # Optional — API client logic
Important
Skills are loaded from ~/.nemo/skills/ at runtime. The skill id in skill.json must match the folder name.
The __init__.py File
Your __init__.py must export exactly four things:
| Export | Type | What it does |
|---|---|---|
| TOOLS | dict[str, Callable] | Maps tool names to async handler functions |
| TOOL_SCHEMAS | list[dict] | OpenAI function-calling format — tells the LLM what tools exist |
| CREDENTIAL_MAP | dict[str, dict] | Auto-injects vault credentials into tool calls. Empty = no auth |
| SYSTEM_PROMPT | str | Context and instructions for the LLM when using this skill |
Tool Handlers
Every tool handler is an async def that accepts **kwargs and returns a dict:
async def myskill_action(
param1: str = "",
param2: int = 0,
**kwargs: Any, # Always include **kwargs
) -> dict[str, Any]:
# Do your work here
result = await some_async_operation()
# Return success
return {"status": "ok", "data": result}
# Or return error
return {"error": "Something went wrong"}
# Register in TOOLS dict
TOOLS = {
"myskill.action": myskill_action,
}
async def
**kwargs for forward compatibility
"error" key on failure
prefix.action format (e.g. weather.get, gmail.send)
Tool Schemas
Schemas tell the LLM what each tool does and what parameters it accepts. Uses OpenAI's function-calling format:
TOOL_SCHEMAS = [
{
"type": "function",
"function": {
"name": "myskill.action", # Must match TOOLS key exactly
"description": "What this tool does. Be specific — the LLM reads this.",
"parameters": {
"type": "object",
"properties": {
"param1": {
"type": "string",
"description": "What this parameter is for",
},
"param2": {
"type": "integer",
"description": "Optional numeric value",
"default": 10,
},
},
"required": ["param1"], # Only truly required params
},
},
},
]
Critical
The name in TOOL_SCHEMAS must match the key in TOOLS exactly. If they don't match, the tool won't be found at runtime.
Credentials & Authentication
If your skill needs API keys or OAuth tokens, use CREDENTIAL_MAP to tell Nemo where to find them in the encrypted vault. Credentials are automatically injected into tool calls — never put them in TOOL_SCHEMAS.
# Maps tool patterns to vault credential paths
CREDENTIAL_MAP = {
"gmail.*": { # Applies to all gmail.* tools
"access_token": "credentials.gmail.access_token",
},
"stripe.charge": { # Applies to one specific tool
"api_key": "credentials.stripe.api_key",
},
}
# The access_token is automatically injected as a parameter:
async def gmail_send(
to: str = "",
subject: str = "",
body: str = "",
access_token: str = "", # Injected by bridge — never in schema!
**kwargs,
) -> dict:
# Use access_token here
...
No authentication needed? Just use an empty dict:
CREDENTIAL_MAP = {}
skill.json Reference
The metadata file that describes your skill to Nemo:
{
"id": "my_weather", // Must match folder name
"name": "Weather Lookup", // Display name in UI
"description": "Get weather", // Short description
"version": "1.0.0", // Semantic versioning
"icon": "cloud-sun", // Lucide icon name
"category": "productivity", // See categories below
"enabled": true,
"consent_defaults": {
"weather.get": "execute" // Auto-run (read-only tools)
},
"pii_policy": {
"ssn": "block", // Never process SSNs
"credit_card": "block", // Never process card numbers
"api_key": "redact", // Strip from output
"phone": "pass", // Allow (needed for skill)
"email_address": "pass" // Allow (needed for skill)
}
}
Categories
Consent Levels
Every tool has a consent level that controls whether it runs automatically or needs user approval:
| Level | Behavior | Use for |
|---|---|---|
| execute | Runs automatically, no user approval | Read-only tools, lookups, searches |
| draft | Queued for user review before running | Sending emails, posting, payments |
| observe | Logged but never executed | Audit-only, monitoring |
PII Policies
Control how your skill handles sensitive data. Set these in skill.json under pii_policy:
| Action | What happens |
|---|---|
| block | Tool call is rejected if this PII type is detected |
| redact | PII is stripped from the data before processing |
| mask | PII is partially hidden (e.g. ***-**-1234) |
| pass | PII is allowed through (use when the skill needs it) |
PII types: ssn, credit_card, api_key, password, phone, email_address, address
Advanced Patterns
Multiple Tools
A single skill can expose multiple tools. The planner chains them automatically:
TOOLS = {
"crm.search": crm_search, # Read-only lookup
"crm.create": crm_create, # Create a record
"crm.update": crm_update, # Update a record
"crm.delete": crm_delete, # Delete a record
}
# Different consent levels per tool
# In skill.json:
# "consent_defaults": {
# "crm.search": "execute", <-- auto-run
# "crm.create": "draft", <-- needs approval
# "crm.update": "draft",
# "crm.delete": "draft"
# }
Batch / Sequence Tools
If your skill involves multiple steps, create a single batch tool instead of multiple small ones. This reduces LLM roundtrips (the #1 source of slowness):
async def deploy_run_sequence(
steps: list[dict] = [], # [{"action": "build"}, {"action": "test"}, ...]
**kwargs,
) -> dict:
results = []
for step in steps:
result = await _execute_step(step)
results.append(result)
return {"status": "ok", "results": results}
Variable Passing Between Tools
The planner passes data between tools automatically using $N references:
// The planner generates plans like this automatically:
[
{"tool": "vault.read", "args": {"key": "profile"}, "save_as": "$1"},
{"tool": "crm.create", "args": {"name": "$1.name", "email": "$1.email"}}
]
// $1 = result from step 1, $1.name = result["name"]
You don't need to implement this — the planner and executor handle it automatically.
Desktop & Browser Access
Skills can request access to the desktop relay (pyautogui) or browser relay (Playwright CDP):
# Optional: receive injected relays from the agent runner
_desktop_relay = None
_cdp_relay = None
def set_desktop_relay(relay):
global _desktop_relay
_desktop_relay = relay
def set_relay(relay):
global _cdp_relay
_cdp_relay = relay
Testing Your Skill
Deploy to runtime
cp -r your_skill/ ~/.nemo/skills/your_skill/
Restart Nemo and check logs
Look for: [Runtime] Loaded skill: your_skill (N tools)
Test in chat
Type a natural language request that should trigger your skill. The planner will automatically discover and use your tools.
Common issues
- Skill not loading? Check
__init__.pyexists and has TOOLS dict - ToolNotFound? Skill not deployed to
~/.nemo/skills/ - Tools fail? TOOLS keys must match TOOL_SCHEMAS function names exactly
- Credentials missing? Check CREDENTIAL_MAP patterns
Publishing to the Marketplace
Once your skill works locally, publish it to the Nemo Marketplace for the world to use. The entire process takes about 5 minutes.
Open Marketplace in Nemo
Go to the sidebar → Marketplace → click the "Sell a Skill" tab at the top.
Connect your Stripe account
First-time sellers connect a Stripe account for payouts. Takes 2 minutes. You can use an existing Stripe account or create a new one. Nemo uses Stripe Connect — we never see your bank details.
Fill in your listing
Provide the details that buyers will see:
Upload your skill package
Select your skill folder. Nemo validates the structure (checks for __init__.py, skill.json, required exports), packages it into a signed archive, and uploads it.
Set your price
Choose free or set any price from $0.99 to $99.99. You can change the price anytime. Consider these pricing tiers:
| Price | Best for | You earn |
|---|---|---|
| Free | Building reputation, open-source, simple utilities | $0 (but great for visibility) |
| $1.99–$4.99 | Single-purpose tools, niche utilities | $1.39–$3.49 per sale |
| $4.99–$14.99 | Multi-tool skills, API integrations | $3.49–$10.49 per sale |
| $14.99–$49.99 | Complex automation suites, enterprise tools | $10.49–$34.99 per sale |
Publish
Hit publish and your skill is live immediately. Users can find it by browsing, searching, or when the planner recommends it for a task.
Updating your skill
Ship updates anytime. Go to Marketplace → My Skills → select your skill → upload new version. Users get the update automatically on next restart. Version history is preserved so users can roll back if needed.
Payments & Revenue
Nemo handles all payment processing through Stripe. Here's exactly how the money flows from buyer to your bank account.
Revenue Split
Nemo's 30% covers: Stripe processing fees (~3%), marketplace hosting, CDN delivery, payment infrastructure, and customer support.
How a sale works
Example: What you actually earn
| Skill price | Total sales | Gross revenue | After refunds (est. 5%) | Your 70% |
|---|---|---|---|---|
| $4.99 | 10,000 | $49,900 | $47,405 | $33,183 |
| $9.99 | 10,000 | $99,900 | $94,905 | $66,433 |
| $24.99 | 100,000 | $2,499,000 | $2,374,050 | $1,661,835 |
Payout Schedule
Here's when and how you get paid:
48-Hour Hold Period
Every sale enters a 48-hour hold. During this time, the buyer can request a full refund — no questions asked. If they refund, the sale is cancelled and nothing is charged. If they don't, the sale confirms and your share is added to your balance. This hold protects buyers and keeps the marketplace trustworthy.
$50 Minimum Payout
Once your confirmed balance reaches $50.00 or more, Stripe automatically initiates a payout to your linked bank account. This threshold keeps transaction fees reasonable. Your balance carries over month to month until it hits $50.
Bank Deposit Timeline
Once a payout is triggered, Stripe deposits it to your bank account within 2–5 business days depending on your country. US accounts typically receive funds in 2 days. You can track all payouts in your Stripe dashboard.
Sales Dashboard
Track everything from the Nemo app: total sales, revenue, refund rate, pending balance, payout history, and per-skill analytics. See which skills perform best, what users search for, and where your downloads come from.
Example timeline
Refund policy
The 48-hour refund window is automatic. After 48 hours, refunds are handled case-by-case. If a skill is broken or misrepresented, we'll issue a refund and notify you. Consistently high refund rates (>15%) may result in your listing being flagged for review.
Bounty Board
Users post bounties for skills they need but don't exist yet. Build the skill, claim the bounty, and get paid directly. It's a win-win: users get exactly what they need, developers get paid for their work.
How bounties work
User posts a bounty
A user describes the skill they want and sets a reward amount (e.g. "$50 for a Shopify inventory tracker"). The reward is held in escrow by Stripe.
Developers browse & claim
You browse open bounties in the Marketplace → Bounty Board tab. When you find one you can build, claim it. Multiple developers can work on the same bounty — the user picks the best submission.
Build & submit your skill
Build the skill, test it locally, then submit it against the bounty. Include a description of how it works and any setup instructions.
User reviews & accepts
The bounty poster reviews submissions, tests them, and accepts the one that best meets their needs. They have 7 days to review before the bounty auto-accepts the highest-rated submission.
You get paid
The bounty reward is released from escrow to your Stripe balance. Same payout rules apply: $50 minimum, 2–5 business days to your bank. Your skill is also published to the marketplace — so you can earn ongoing revenue from future sales too.
Example bounties
"I want a skill that monitors my Shopify store and sends me a summary of new orders every morning via email. Should include order total, items, and customer name."
"Bi-directional sync between an Airtable base and a Google Sheet. Should handle new rows, updates, and deletions. Run on a schedule."
"Resize a folder of images to specific dimensions, add a text watermark, and save to a new folder. Support PNG, JPG, WebP."
Pro tip
Bounties are a great way to build your reputation. Even if the bounty reward is small, the skill you build gets published to the marketplace where it can earn ongoing revenue. A $40 bounty skill that gets 10,000 sales at $4.99 earns you over $33,000 total.
Full Working Examples
Complete, copy-pasteable skills you can use as templates. Each example covers a different pattern.
Example 1: Crypto Price Checker
Pattern: Single tool, no auth, external API
import httpx
from typing import Any
async def crypto_price(symbol: str = "", **kwargs: Any) -> dict[str, Any]:
"""Get current price and 24h change for a cryptocurrency."""
if not symbol:
return {"error": "Please specify a crypto symbol (e.g. BTC, ETH)"}
symbol = symbol.upper().strip()
try:
async with httpx.AsyncClient() as client:
resp = await client.get(
f"https://api.coingecko.com/api/v3/simple/price",
params={
"ids": _SYMBOL_MAP.get(symbol, symbol.lower()),
"vs_currencies": "usd",
"include_24hr_change": "true",
"include_market_cap": "true",
},
timeout=10,
)
data = resp.json()
coin_id = _SYMBOL_MAP.get(symbol, symbol.lower())
if coin_id not in data:
return {"error": f"Unknown symbol: {symbol}. Try BTC, ETH, SOL, etc."}
info = data[coin_id]
return {
"symbol": symbol,
"price_usd": info.get("usd"),
"change_24h_pct": round(info.get("usd_24h_change", 0), 2),
"market_cap_usd": info.get("usd_market_cap"),
}
except Exception as e:
return {"error": f"Failed to fetch price: {e}"}
# Common symbol → CoinGecko ID mapping
_SYMBOL_MAP = {
"BTC": "bitcoin", "ETH": "ethereum", "SOL": "solana",
"ADA": "cardano", "DOGE": "dogecoin", "XRP": "ripple",
"DOT": "polkadot", "AVAX": "avalanche-2",
}
TOOLS = {"crypto.price": crypto_price}
TOOL_SCHEMAS = [{
"type": "function",
"function": {
"name": "crypto.price",
"description": "Get current price and 24h change for a cryptocurrency by symbol.",
"parameters": {
"type": "object",
"properties": {
"symbol": {"type": "string", "description": "Crypto symbol (BTC, ETH, SOL, etc.)"}
},
"required": ["symbol"],
},
},
}]
CREDENTIAL_MAP = {}
SYSTEM_PROMPT = "You are a crypto price assistant. Use crypto.price to look up cryptocurrency prices."
Example 2: Quick Notes
Pattern: Multi-tool, local filesystem, no auth
import json
from datetime import datetime
from pathlib import Path
from typing import Any
_NOTES_FILE = Path.home() / ".nemo" / "notes.json"
def _load() -> list[dict]:
if _NOTES_FILE.exists():
return json.loads(_NOTES_FILE.read_text("utf-8"))
return []
def _save(notes: list[dict]) -> None:
_NOTES_FILE.parent.mkdir(parents=True, exist_ok=True)
_NOTES_FILE.write_text(json.dumps(notes, indent=2), "utf-8")
async def notes_add(title: str = "", content: str = "", **kwargs) -> dict:
"""Save a new note."""
if not content:
return {"error": "Note content is required"}
notes = _load()
note = {
"id": len(notes) + 1,
"title": title or content[:50],
"content": content,
"created": datetime.now().isoformat(),
}
notes.append(note)
_save(notes)
return {"status": "ok", "note_id": note["id"], "title": note["title"]}
async def notes_list(query: str = "", **kwargs) -> dict:
"""List all notes, optionally filtered by search query."""
notes = _load()
if query:
q = query.lower()
notes = [n for n in notes if q in n["title"].lower() or q in n["content"].lower()]
return {"status": "ok", "count": len(notes), "notes": notes[-20:]}
async def notes_delete(note_id: int = 0, **kwargs) -> dict:
"""Delete a note by ID."""
notes = _load()
notes = [n for n in notes if n["id"] != note_id]
_save(notes)
return {"status": "ok"}
TOOLS = {
"notes.add": notes_add,
"notes.list": notes_list,
"notes.delete": notes_delete,
}
TOOL_SCHEMAS = [
{"type": "function", "function": {
"name": "notes.add",
"description": "Save a new note with an optional title.",
"parameters": {"type": "object", "properties": {
"title": {"type": "string", "description": "Note title (auto-generated if empty)"},
"content": {"type": "string", "description": "Note body text"},
}, "required": ["content"]},
}},
{"type": "function", "function": {
"name": "notes.list",
"description": "List saved notes, optionally filtered by a search query.",
"parameters": {"type": "object", "properties": {
"query": {"type": "string", "description": "Search term to filter notes"},
}},
}},
{"type": "function", "function": {
"name": "notes.delete",
"description": "Delete a note by its ID number.",
"parameters": {"type": "object", "properties": {
"note_id": {"type": "integer", "description": "ID of the note to delete"},
}, "required": ["note_id"]},
}},
]
CREDENTIAL_MAP = {}
SYSTEM_PROMPT = """You are a note-taking assistant. You have three tools:
- notes.add: Save a new note
- notes.list: Show saved notes (use query to search)
- notes.delete: Remove a note by ID
Always confirm what you saved or found."""
Example 3: Slack Notifier
Pattern: API key auth via credential map
import httpx
from typing import Any
async def slack_send(
channel: str = "",
message: str = "",
bot_token: str = "", # Injected from vault via CREDENTIAL_MAP
**kwargs: Any,
) -> dict[str, Any]:
"""Send a message to a Slack channel."""
if not channel or not message:
return {"error": "Both channel and message are required"}
if not bot_token:
return {"error": "Slack bot token not configured. Add it in Settings > Vault."}
async with httpx.AsyncClient() as client:
resp = await client.post(
"https://slack.com/api/chat.postMessage",
headers={"Authorization": f"Bearer {bot_token}"},
json={"channel": channel, "text": message},
timeout=10,
)
data = resp.json()
if not data.get("ok"):
return {"error": data.get("error", "Slack API error")}
return {"status": "ok", "channel": channel, "ts": data.get("ts")}
TOOLS = {"slack.send": slack_send}
TOOL_SCHEMAS = [{
"type": "function",
"function": {
"name": "slack.send",
"description": "Send a message to a Slack channel.",
"parameters": {
"type": "object",
"properties": {
"channel": {"type": "string", "description": "Slack channel name or ID (e.g. #general)"},
"message": {"type": "string", "description": "Message text to send"},
},
"required": ["channel", "message"],
},
},
}]
# bot_token is injected from vault — user stores it in Settings > Vault
CREDENTIAL_MAP = {
"slack.*": {"bot_token": "credentials.slack.bot_token"},
}
SYSTEM_PROMPT = "You are a Slack messaging assistant. Use slack.send to post messages to channels."
Built-in reference skills
These are the built-in skills that ship with Nemo. Study their source code for production-grade patterns:
Single tool (gmail.send), OAuth credentials, immediate execution. Best starting template for API skills.
Two tools, no auth, local file parsing (PDF, DOCX, TXT, CSV). Great template for local-only skills.
Multi-tool chaining (gmail.list → gmail.read → gmail.label). Complex API skill with tool orchestration.
Desktop relay + vision LLM, batch sequences, playbook learning. Reference for desktop automation skills.
Desktop vision for browser-based messaging. Opens WhatsApp Web and uses vision to send messages.
Browser CDP + LLM matching. Reads DOM fields, matches to user profile, batch fills. Uses browser.auto_fill.
Writing Good System Prompts
The SYSTEM_PROMPT is the LLM's instruction manual for your skill. A good prompt means the LLM uses your tools correctly on the first try. A bad one means wasted API calls and confused results.
Good system prompt
SYSTEM_PROMPT = """You are a note-taking assistant.
TOOLS:
- notes.add(title, content): Save a new note. Title is optional.
- notes.list(query): Show notes. Use query to search.
- notes.delete(note_id): Delete by ID. List notes first to find the ID.
RULES:
- ALWAYS call a tool. Never respond with just text.
- After adding a note, confirm what was saved.
- After listing, format notes as a numbered list.
- After deleting, confirm which note was removed.
"""
Bad system prompt
SYSTEM_PROMPT = "You help with notes."
# Too vague. LLM doesn't know which tools exist,
# when to use them, or how to format results.
Best Practices
Minimize LLM roundtrips
Every tool call = 1 full LLM API roundtrip (~3-8 seconds + token costs). If your skill needs 5 sequential steps, create a single run_sequence batch tool that does all 5 internally. This is the #1 performance optimization.
Handle errors gracefully
Always return a dict with an "error" key on failure. Never raise exceptions — catch them and return a user-friendly error message. The planner will show the error to the user and can retry with different parameters.
Use pathlib for file paths
Nemo runs on Windows and Mac. Always use pathlib.Path instead of string concatenation for file paths. This prevents path separator bugs (/ vs \).
Never log or print credentials
Credentials are injected at runtime. Never print() or logger.info() them. Nemo's DLP scanner strips credentials from LLM output, but don't rely on it — keep secrets out of your code entirely.
Write descriptive tool schemas
The LLM reads your schema descriptions to decide which tool to use and what parameters to pass. Be specific: "Send an email via Gmail" is better than "Send". Include examples in parameter descriptions when the format matters (e.g. "Date in YYYY-MM-DD format").
Set reasonable timeouts
External API calls should have a timeout=10 (seconds) at minimum. Nemo's default tool timeout is 30 seconds. If your tool needs more time (e.g. file processing, vision), it's automatically detected — but keep it under 180 seconds.
Ready to build?
Create your first skill in minutes. Publish it to the marketplace and start earning.