Nemo Nemo Developer Docs
Developer Documentation

Build Skills for Nemo

Create AI automation skills, publish them to the marketplace, and earn money. A skill is just a Python file with async functions — if you can write Python, you can build a skill.

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

1

Create the skill folder

Terminal
# Create your skill directory
mkdir -p ~/.nemo/skills/my_weather/
2

Write the skill code

~/.nemo/skills/my_weather/__init__.py
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."
3

Add skill metadata

~/.nemo/skills/my_weather/skill.json
{
  "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"
  }
}
4

Restart Nemo and test

Restart the app. Your skill loads automatically. Now just type:

> What's the weather in Tokyo?

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:

~/.nemo/skills/your_skill/
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:

Tool handler pattern
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,
}
All handlers must be async def
Always accept **kwargs for forward compatibility
Return a dict — include "error" key on failure
Tool names follow prefix.action format (e.g. weather.get, gmail.send)
Never hardcode credentials — use CREDENTIAL_MAP

Tool Schemas

Schemas tell the LLM what each tool does and what parameters it accepts. Uses OpenAI's function-calling format:

Schema 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.

Credential injection
# 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:

skill.json
{
  "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

communication
productivity
documents
automation
research
creative
finance
other

Icons

Use any icon name from Lucide Icons.

Examples: mail, cloud-sun, file-text, calculator, music

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

1

Deploy to runtime

cp -r your_skill/ ~/.nemo/skills/your_skill/
2

Restart Nemo and check logs

Look for: [Runtime] Loaded skill: your_skill (N tools)

3

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__.py exists 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.

1

Open Marketplace in Nemo

Go to the sidebar → Marketplace → click the "Sell a Skill" tab at the top.

2

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.

3

Fill in your listing

Provide the details that buyers will see:

Skill name & description
Category (productivity, automation, etc.)
Screenshots (up to 5)
Tags for discoverability
README / documentation
Support URL or email
4

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.

5

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
6

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

70%
You keep
/
30%
Nemo keeps

Nemo's 30% covers: Stripe processing fees (~3%), marketplace hosting, CDN delivery, payment infrastructure, and customer support.

How a sale works

1
User buys your skill — They click "Buy" in the marketplace. Stripe processes the payment instantly.
2
48-hour refund window — The buyer has 48 hours to request a full refund, no questions asked. This protects users and builds trust in the marketplace. During this window, the sale is marked as "pending".
3
Sale confirmed — After 48 hours with no refund request, the sale is confirmed. Your 70% share is added to your pending balance.
4
Payout to your bank — Once your pending balance reaches $50 minimum, Stripe automatically deposits it to your linked bank account.

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

Mon 10 AM
User buys your skill for $9.99
Mon–Wed
48-hour refund window (sale pending)
Wed 10 AM
Sale confirmed — $6.99 added to your balance
When ≥ $50
Stripe auto-deposits to your bank (2–5 business days)

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

1

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.

2

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.

3

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.

4

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.

5

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

Shopify Order Notifier
$75

"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."

Posted by @storeowner 3 days ago 2 submissions
Airtable ↔ Google Sheets Sync
$120

"Bi-directional sync between an Airtable base and a Google Sheet. Should handle new rows, updates, and deletions. Run on a schedule."

Posted by @datafreak 1 day ago 0 submissions
Bulk Image Resizer & Watermarker
$40

"Resize a folder of images to specific dimensions, add a text watermark, and save to a new folder. Support PNG, JPG, WebP."

Posted by @photographer 5 days ago 4 submissions

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

~/.nemo/skills/crypto_price/__init__.py
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."
Test it: "What's the price of Bitcoin?" or "Compare ETH and SOL prices"

Example 2: Quick Notes

Pattern: Multi-tool, local filesystem, no auth

~/.nemo/skills/quick_notes/__init__.py
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."""
Test it: "Save a note: buy groceries tomorrow""Show my notes""Delete note 1"

Example 3: Slack Notifier

Pattern: API key auth via credential map

~/.nemo/skills/slack_notify/__init__.py
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."
Test it: "Send 'deploy complete' to #engineering on Slack"

Built-in reference skills

These are the built-in skills that ship with Nemo. Study their source code for production-grade patterns:

Email Composer Simple

Single tool (gmail.send), OAuth credentials, immediate execution. Best starting template for API skills.

Document Summarizer Moderate

Two tools, no auth, local file parsing (PDF, DOCX, TXT, CSV). Great template for local-only skills.

Email Triage Advanced

Multi-tool chaining (gmail.listgmail.readgmail.label). Complex API skill with tool orchestration.

App Launcher Advanced

Desktop relay + vision LLM, batch sequences, playbook learning. Reference for desktop automation skills.

WhatsApp Messenger Advanced

Desktop vision for browser-based messaging. Opens WhatsApp Web and uses vision to send messages.

Form Filler Advanced

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.
List all tools and their parameters explicitly
Include "ALWAYS call a tool" to prevent text-only responses
Describe the expected workflow (list first, then delete)
Tell the LLM how to format results for the user

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.