Marketplace / Docs / Tool Authoring

Tool Package Authoring Guide

Create, test, and publish tool packages that extend Snippbot with new agent-invokable tools.

Jump to section

A comprehensive guide to creating, testing, and publishing tool packages for the Singularity AI Agent Marketplace.


Overview

Tool packages extend Snippbot's capabilities by adding new tools that AI agents can invoke during task execution. A tool package is a self-contained archive containing executable code, a manifest (singularity.json), and the metadata necessary for Snippbot to discover, validate, sandbox, and run the tool.

When a tool package is installed, Snippbot:

  1. Validates the package structure, entry points, and permission declarations.
  2. Assigns a permission tier (0-4) based on the declared permissions.
  3. Prompts the user for permission grants through the PermissionDialog UI.
  4. Registers the tool(s) in the SkillsStore so agents can discover and invoke them.
  5. Isolates execution within resource limits appropriate to the assigned tier.

Tool packages support four execution types: Python, Bash, MCP Server, and HTTP. Each type has its own entry point conventions, dependency management, and runtime behavior.

Package Size Limits

Artifact Type Max Package Size
tool 50 MB
mcp_server 50 MB
agent 50 MB
workflow 5 MB
hook 5 MB

Individual files within a package are limited to 10 MB each.


Quick Start

This section walks through creating a minimal Python tool package from scratch.

Step 1: Scaffold the Package

mkdir my-word-counter && cd my-word-counter
snippbot marketplace init . --type tool

This creates:

  • singularity.json -- the package manifest
  • .singularityignore -- files to exclude from the published archive

Step 2: Edit the Manifest

Replace the generated singularity.json with your tool's configuration:

{
  "name": "@your-name/word-counter",
  "version": "1.0.0",
  "type": "tool",
  "displayName": "Word Counter",
  "description": "Counts words, lines, and characters in text or files within the workspace.",
  "author": {
    "name": "your-name",
    "email": "[email protected]"
  },
  "license": "MIT",
  "category": "productivity",
  "tags": ["text", "analysis", "utility"],
  "snippbot": {
    "minVersion": "0.9.0"
  },
  "permissions": {
    "filesystem": {
      "scope": "workspace",
      "read": true,
      "write": false
    },
    "network": {
      "required": false
    },
    "shell": {
      "required": false
    }
  },
  "tool": {
    "executionType": "python",
    "entryPoint": "main.py",
    "tools": [
      {
        "name": "count_words",
        "description": "Count words, lines, and characters in the given text or a file at the given path.",
        "inputSchema": {
          "type": "object",
          "properties": {
            "text": {
              "type": "string",
              "description": "Text to analyze. Mutually exclusive with 'file_path'."
            },
            "file_path": {
              "type": "string",
              "description": "Path to a file within the workspace to analyze."
            }
          }
        }
      }
    ]
  }
}

Step 3: Write the Tool Code

Create main.py:

"""Word counter tool for Snippbot."""

import json
import sys
from pathlib import Path


def count_words(text: str) -> dict:
    """Count words, lines, and characters in text."""
    lines = text.splitlines()
    words = text.split()
    return {
        "words": len(words),
        "lines": len(lines),
        "characters": len(text),
        "characters_no_spaces": len(text.replace(" ", "")),
    }


def main():
    """Entry point: reads JSON input from stdin, writes JSON output to stdout."""
    input_data = json.loads(sys.stdin.read())
    tool_name = input_data.get("tool", "count_words")
    params = input_data.get("input", {})

    if tool_name == "count_words":
        text = params.get("text", "")
        file_path = params.get("file_path")

        if file_path:
            path = Path(file_path)
            if not path.exists():
                print(json.dumps({"error": f"File not found: {file_path}"}))
                sys.exit(1)
            text = path.read_text(encoding="utf-8")

        result = count_words(text)
        print(json.dumps(result))
    else:
        print(json.dumps({"error": f"Unknown tool: {tool_name}"}))
        sys.exit(1)


if __name__ == "__main__":
    main()

Step 4: Test Locally

# Dry-run validation (does not publish)
snippbot marketplace publish --dry-run .

# Manual test
echo '{"tool": "count_words", "input": {"text": "hello world foo bar"}}' | python main.py

Step 5: Publish

snippbot marketplace login
snippbot marketplace publish .

singularity.json Manifest Reference

The singularity.json file is the root manifest for every Singularity package. For tool packages, it includes tool-specific configuration blocks in addition to the standard fields.

Required Fields

Field Type Description
name string Scoped package name: @publisher/package-name. Publisher and package names must be lowercase alphanumeric with hyphens, 3-50 characters each.
version string Semantic version (MAJOR.MINOR.PATCH). Must follow semver strictly.
type string Must be "tool" for tool packages.
displayName string Human-readable display name shown in the marketplace UI.
description string Package description, 10-500 characters.
author object or string Author info. Object form: {"name": "...", "email": "...", "url": "..."}.
license string SPDX license identifier (e.g., MIT, Apache-2.0, PROPRIETARY).

Optional Standard Fields

Field Type Default Description
category string "productivity" One of: development, research, data, writing, devops, design, business, security, ai-ml, productivity, code-tools, api-tools, database-tools, testing, monitoring.
tags string[] [] Up to 20 tags, each 2-50 characters.
keywords string[] [] Up to 20 keywords for search.
snippbot.minVersion string "" Minimum compatible Snippbot version.
snippbot.maxVersion string "" Maximum compatible Snippbot version.
snippbot.features string[] [] Required Snippbot features.
dependencies object {} Map of package-name to version constraint.
repository string "" Repository URL.
homepage string "" Homepage URL.
icon string "" Relative path to an icon file in the package.
files.include string[] [] Globs of files to include in the published archive.
files.exclude string[] [] Globs of files to exclude.
private boolean false If true, the package is not listed publicly.

Tool-Specific Fields: `permissions`

The permissions block is required for tool packages. It declares what system resources the tool needs. If omitted, the package runs in Tier 0 (Sandbox) with no access to network, filesystem writes, or subprocesses.

{
  "permissions": {
    "filesystem": {
      "scope": "workspace",
      "read": true,
      "write": false,
      "reason": "Reads project files for analysis"
    },
    "network": {
      "hosts": ["api.example.com"],
      "reason": "Calls the Example API for enrichment"
    },
    "shell": {
      "commands": ["git", "npm"],
      "reason": "Runs git and npm for project inspection"
    },
    "environment": {
      "variables": ["EXAMPLE_API_KEY"],
      "reason": "Needs API key for authentication"
    }
  }
}

See the Permission Manifest section for the full reference.

Tool-Specific Fields: `tool`

The tool configuration block defines execution behavior and the tools exposed by the package.

{
  "tool": {
    "executionType": "python",
    "entryPoint": "main.py",
    "pythonDependencies": ["httpx>=0.25.0", "pydantic>=2.0"],
    "tools": [
      {
        "name": "analyze_code",
        "description": "Analyze source code for complexity metrics.",
        "inputSchema": {
          "type": "object",
          "properties": {
            "file_path": {
              "type": "string",
              "description": "Path to the source file to analyze."
            },
            "language": {
              "type": "string",
              "enum": ["python", "javascript", "typescript"],
              "description": "Programming language of the file."
            }
          },
          "required": ["file_path"]
        }
      }
    ]
  }
}
Field Type Description
tool.executionType string One of: python, bash, mcp_server, http.
tool.entryPoint string Relative path to the main entry point file. If omitted, the installer searches for default entry point files (see Entry Point Patterns).
tool.pythonDependencies string[] Pip requirement strings for Python tools. A requirements.txt in the package root is also recognized.
tool.tools object[] Array of tool definitions. If omitted, the entry point is registered as a single tool.
tool.tools[].name string Tool name. Must be lowercase alphanumeric with underscores, 3-64 characters. Must start with a letter.
tool.tools[].description string Human-readable description shown to the agent.
tool.tools[].inputSchema object JSON Schema describing the tool's input parameters.
tool.tools[].executionConfig object Optional per-tool execution overrides.

Execution Types

Python (`executionType: "python"`)

Python tools run in an isolated virtual environment. Dependencies are installed automatically from requirements.txt or the pythonDependencies manifest field.

How it works:

  1. On install, a .venv is created inside the package directory.
  2. Dependencies from requirements.txt (or tool.pythonDependencies) are pip-installed into the venv.
  3. On invocation, the tool's main.py is executed with the venv's Python interpreter.
  4. Input is passed as JSON on stdin. Output is read from stdout as JSON.

Directory structure:

my-python-tool/
  singularity.json
  main.py
  requirements.txt
  src/
    helper.py
    utils.py

Example main.py:

"""A Python tool that fetches and summarizes a web page."""

import json
import sys

import httpx


def fetch_summary(url: str, max_length: int = 500) -> dict:
    response = httpx.get(url, timeout=30.0, follow_redirects=True)
    response.raise_for_status()
    text = response.text[:max_length]
    return {"url": url, "status": response.status_code, "preview": text}


def main():
    data = json.loads(sys.stdin.read())
    tool_name = data.get("tool", "fetch_summary")
    params = data.get("input", {})

    if tool_name == "fetch_summary":
        result = fetch_summary(params["url"], params.get("max_length", 500))
        print(json.dumps(result))
    else:
        print(json.dumps({"error": f"Unknown tool: {tool_name}"}))
        sys.exit(1)


if __name__ == "__main__":
    main()

Example requirements.txt:

httpx>=0.25.0

Example singularity.json (tool block only):

{
  "tool": {
    "executionType": "python",
    "entryPoint": "main.py",
    "tools": [
      {
        "name": "fetch_summary",
        "description": "Fetch a web page and return a text preview.",
        "inputSchema": {
          "type": "object",
          "properties": {
            "url": {
              "type": "string",
              "description": "The URL to fetch."
            },
            "max_length": {
              "type": "integer",
              "description": "Maximum characters to return.",
              "default": 500
            }
          },
          "required": ["url"]
        }
      }
    ]
  },
  "permissions": {
    "network": {
      "hosts": ["*"],
      "reason": "Fetches arbitrary URLs as requested by the user."
    }
  }
}

Bash (`executionType: "bash"`)

Bash tools execute a shell script. They follow the same stdin/stdout JSON protocol as Python tools.

Directory structure:

my-bash-tool/
  singularity.json
  main.sh

Example main.sh:

#!/usr/bin/env bash
set -euo pipefail

# Read JSON input from stdin
INPUT=$(cat)
TOOL=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('tool',''))")
FILE_PATH=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('input',{}).get('file_path',''))")

if [ "$TOOL" = "line_count" ]; then
    if [ -z "$FILE_PATH" ]; then
        echo '{"error": "file_path is required"}'
        exit 1
    fi
    if [ ! -f "$FILE_PATH" ]; then
        echo "{\"error\": \"File not found: $FILE_PATH\"}"
        exit 1
    fi
    LINES=$(wc -l < "$FILE_PATH")
    WORDS=$(wc -w < "$FILE_PATH")
    BYTES=$(wc -c < "$FILE_PATH")
    echo "{\"lines\": $LINES, \"words\": $WORDS, \"bytes\": $BYTES}"
else
    echo "{\"error\": \"Unknown tool: $TOOL\"}"
    exit 1
fi

Example singularity.json (tool block only):

{
  "tool": {
    "executionType": "bash",
    "entryPoint": "main.sh",
    "tools": [
      {
        "name": "line_count",
        "description": "Count lines, words, and bytes in a file.",
        "inputSchema": {
          "type": "object",
          "properties": {
            "file_path": {
              "type": "string",
              "description": "Path to the file to count."
            }
          },
          "required": ["file_path"]
        }
      }
    ]
  },
  "permissions": {
    "filesystem": {
      "scope": "workspace",
      "read": true,
      "write": false
    }
  }
}

MCP Server (`executionType: "mcp_server"`)

MCP (Model Context Protocol) server packages register a long-running server process that exposes tools via the MCP protocol. Instead of the stdin/stdout JSON convention, MCP servers use the standard MCP transport (stdio or SSE) and are managed by Snippbot's SkillsStore.

Directory structure:

my-mcp-server/
  singularity.json
  mcp.json
  src/
    server.py
  requirements.txt

Example mcp.json:

{
  "transport": "stdio",
  "command": "python",
  "args": ["-m", "src.server"],
  "env": {},
  "tools_provided": [
    "search_database",
    "query_table"
  ]
}

Example singularity.json:

{
  "name": "@your-name/db-explorer",
  "version": "1.0.0",
  "type": "tool",
  "displayName": "Database Explorer",
  "description": "MCP server that provides tools for exploring SQLite and PostgreSQL databases.",
  "author": { "name": "your-name" },
  "license": "MIT",
  "category": "database-tools",
  "tool": {
    "executionType": "mcp_server",
    "entryPoint": "mcp.json"
  },
  "permissions": {
    "network": {
      "hosts": ["localhost"],
      "reason": "Connects to local database servers."
    },
    "filesystem": {
      "scope": "workspace",
      "read": true,
      "write": false,
      "reason": "Reads SQLite database files from the workspace."
    }
  }
}

When installed, the MCP server is registered in the SkillsStore under the name pkg__{publisher}__{package_name}. If the command is python, the installer automatically resolves it to the venv's Python executable after installing dependencies.

Example src/server.py:

"""MCP server for database exploration."""

import json
import sys


def handle_request(request: dict) -> dict:
    method = request.get("method", "")

    if method == "tools/list":
        return {
            "tools": [
                {
                    "name": "search_database",
                    "description": "Search for records in a database table.",
                    "inputSchema": {
                        "type": "object",
                        "properties": {
                            "db_path": {"type": "string"},
                            "query": {"type": "string"}
                        },
                        "required": ["db_path", "query"]
                    }
                }
            ]
        }

    if method == "tools/call":
        tool_name = request.get("params", {}).get("name", "")
        arguments = request.get("params", {}).get("arguments", {})
        # Tool implementation here...
        return {"content": [{"type": "text", "text": "Results..."}]}

    return {"error": {"code": -32601, "message": "Method not found"}}


def main():
    for line in sys.stdin:
        request = json.loads(line.strip())
        response = handle_request(request)
        response["id"] = request.get("id")
        sys.stdout.write(json.dumps(response) + "\n")
        sys.stdout.flush()


if __name__ == "__main__":
    main()

HTTP (`executionType: "http"`)

HTTP tools act as thin wrappers around external REST APIs. Instead of running local code, the tool dispatches requests to a remote HTTP endpoint. The configuration is defined in an http_config.json file.

Directory structure:

my-http-tool/
  singularity.json
  http_config.json

Example http_config.json:

{
  "base_url": "https://api.example.com/v1",
  "auth": {
    "type": "bearer",
    "env_var": "EXAMPLE_API_KEY"
  },
  "tools": [
    {
      "name": "translate_text",
      "method": "POST",
      "path": "/translate",
      "headers": {
        "Content-Type": "application/json"
      },
      "body_template": {
        "text": "{{text}}",
        "source_lang": "{{source_lang}}",
        "target_lang": "{{target_lang}}"
      },
      "response_path": "$.translation"
    }
  ]
}

Example singularity.json (tool block only):

{
  "tool": {
    "executionType": "http",
    "entryPoint": "http_config.json",
    "tools": [
      {
        "name": "translate_text",
        "description": "Translate text between languages using the Example Translation API.",
        "inputSchema": {
          "type": "object",
          "properties": {
            "text": {
              "type": "string",
              "description": "Text to translate."
            },
            "source_lang": {
              "type": "string",
              "description": "Source language code (e.g., 'en')."
            },
            "target_lang": {
              "type": "string",
              "description": "Target language code (e.g., 'es')."
            }
          },
          "required": ["text", "target_lang"]
        }
      }
    ]
  },
  "permissions": {
    "network": {
      "hosts": ["api.example.com"],
      "reason": "Calls the Example Translation API."
    },
    "environment": {
      "variables": ["EXAMPLE_API_KEY"],
      "reason": "API key for authenticating with the translation service."
    }
  }
}

Permission Manifest

The permissions block in singularity.json declares the system resources your tool needs. Snippbot uses this declaration to:

  1. Assign a permission tier that determines resource limits.
  2. Display a permission dialog to the user on install.
  3. Enforce runtime restrictions during tool execution.
  4. Validate consistency during the security scan before publication.

`permissions.filesystem`

Controls access to the local filesystem.

Field Type Description
scope string Access scope. One of: none, workspace, workspace-readonly, home.
read boolean Whether the tool reads files.
write boolean Whether the tool writes files. Requires scope of workspace or home.
reason string Human-readable justification displayed in the permission dialog.

Scope details:

Scope Description Tier Impact
none No filesystem access. Tier 0 (Sandbox)
workspace Access limited to the active project workspace directory. Tier 1 (read) or Tier 2 (write)
workspace-readonly Read-only access to the workspace. Tier 1 (Restricted)
home Access to the user's home directory. Requires explicit user approval. Tier 3 (Elevated)

`permissions.network`

Controls outbound network access.

Field Type Description
hosts string[] List of allowed domain names. IP addresses generate warnings during validation. Wildcards are not supported; list each domain explicitly. Maximum 10 entries recommended.
reason string Justification for network access.

Example:

{
  "network": {
    "hosts": ["api.github.com", "raw.githubusercontent.com"],
    "reason": "Fetches repository metadata and raw file contents from GitHub."
  }
}

`permissions.shell`

Controls the ability to spawn subprocesses.

Field Type Description
commands string[] Allowed command names (e.g., ["git", "npm", "docker"]).
reason string Justification for shell access.

Shell access automatically escalates the package to Tier 3 (Elevated). Certain commands are globally blocked and can never be granted (e.g., rm -rf /, sudo, chmod, chown on system paths).

`permissions.environment`

Controls access to environment variables.

Field Type Description
variables string[] List of environment variable names the tool needs.
reason string Justification for environment variable access.

Certain variables are globally blocked for security reasons and are never exposed to packages:

  • LD_PRELOAD
  • LD_LIBRARY_PATH
  • DYLD_INSERT_LIBRARIES
  • DYLD_LIBRARY_PATH

Tier Assignment Logic

Snippbot automatically assigns a permission tier based on the highest-privilege permission declared:

Condition Assigned Tier
No permissions declared (or all empty) Tier 0 -- SANDBOX
Network access only, filesystem read only, or environment variables only Tier 1 -- RESTRICTED
Filesystem write AND network access Tier 2 -- STANDARD
Shell access or home directory access Tier 3 -- ELEVATED
Full system access (never granted to marketplace packages) Tier 4 -- UNRESTRICTED

You can verify your package's assigned tier by examining the output of snippbot marketplace publish --dry-run.

How Permissions Map to the PermissionDialog UI

When a user installs a tool package, the PermissionDialog presents each declared permission as a card with:

  • Category label (e.g., "Network Access", "Filesystem (Workspace Read/Write)")
  • Scope details (e.g., allowed hosts, filesystem scope)
  • Risk level indicator (safe, standard, elevated)
  • Reason text from your manifest
  • Tier badge showing the overall tier assignment

Users can grant or deny each permission category individually. Denied permissions restrict the tool's runtime capabilities accordingly.


Resource Limits by Tier

Each permission tier enforces hard resource limits on tool execution. These limits cannot be exceeded regardless of what the tool requests.

Resource Tier 0: SANDBOX Tier 1: RESTRICTED Tier 2: STANDARD Tier 3: ELEVATED
cpu_cores 0.5 1.0 2.0 4.0
memory_mb 256 512 1,024 2,048
disk_mb 256 512 2,048 4,096
max_processes 32 64 128 256
max_open_files 512 1,024 2,048 4,096
timeout_seconds 60 120 300 600

Tier 4 (UNRESTRICTED) is never granted to marketplace packages. It is reserved for user-installed local tools.

If your package's resource_limits field requests values exceeding the tier maximum, the values are silently capped to the tier's limit and a warning is logged during installation.


Tool Naming Rules

Tool names must follow strict rules to ensure consistency and avoid collisions across the marketplace.

Name Format in `singularity.json`

  • Lowercase alphanumeric characters and underscores only: [a-z][a-z0-9_]{1,62}[a-z0-9]
  • Must start with a letter.
  • Length: 3-64 characters.
  • No hyphens (use underscores instead).

Valid names: count_words, fetch_url, analyze_code_v2

Invalid names: Count_Words (uppercase), 2fast (starts with digit), a (too short), my-tool (contains hyphen)

Runtime Prefix

At runtime, tool names are automatically prefixed to avoid collisions between packages from different publishers:

pkg__{publisher}__{tool_name}

For example, a tool named count_words in the package @acme/text-utils becomes:

pkg__acme__count_words

If no explicit tools[] array is defined in the manifest, the package name itself is used as the tool name with hyphens converted to underscores:

pkg__acme__text_utils

Agents reference tools by their prefixed names. Users see the displayName in the UI.


Entry Point Patterns

If the tool.entryPoint field is omitted from the manifest, the installer searches for default entry points based on the execution type.

Default Discovery Order

Execution Type Files Searched (in order)
python main.py, src/main.py, __main__.py, src/__main__.py
bash main.sh, run.sh, src/main.sh
mcp_server mcp.json, mcp_config.json, manifest.json
http manifest.json, http_config.json

The first matching file is used. If no match is found, the validation step fails with an error.

Recommendations

  • Always specify tool.entryPoint explicitly to avoid ambiguity.
  • For Python tools, prefer main.py in the package root.
  • For MCP servers, prefer mcp.json in the package root.
  • If your entry point is in a subdirectory, use a relative path: "entryPoint": "src/main.py".

Testing Your Package

Dry-Run Validation

The --dry-run flag validates your package without uploading it to the registry:

cd my-tool-package/
snippbot marketplace publish --dry-run .

This performs:

  1. Manifest validation (required fields, field formats, semver check).
  2. Tool-specific validation (execution type, entry point existence, tool name rules).
  3. Permission declaration validation (scope values, host formats, blocked commands).
  4. Package build (creates the .tar.gz archive and computes the SHA-256 checksum).
  5. Reports warnings (e.g., missing reason fields, IP addresses in hosts, high domain count).

Local Testing

Python tools:

# Test the stdin/stdout protocol directly
echo '{"tool": "my_tool", "input": {"param1": "value1"}}' | python main.py

# Test with the venv if you have dependencies
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
echo '{"tool": "my_tool", "input": {"param1": "value1"}}' | python main.py

Bash tools:

echo '{"tool": "my_tool", "input": {"file_path": "./test.txt"}}' | bash main.sh

MCP server tools:

# Start the server and send a tools/list request
echo '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}' | python -m src.server

Validating the Input Schema

Ensure your inputSchema matches the actual parameters your tool accepts. The agent uses this schema to construct valid input. A mismatch causes runtime errors:

# Validate your schema against sample inputs
import json
import jsonschema

schema = {
    "type": "object",
    "properties": {
        "url": {"type": "string"},
        "max_length": {"type": "integer", "default": 500}
    },
    "required": ["url"]
}

# Should pass
jsonschema.validate({"url": "https://example.com"}, schema)

# Should fail
jsonschema.validate({"max_length": 100}, schema)  # missing required "url"

Publishing

Step 1: Register a Publisher Account

snippbot marketplace register

You will be prompted for:

  • Publisher name -- lowercase, alphanumeric with hyphens (e.g., my-company)
  • Display name -- human-readable name
  • Email -- must be verified
  • Password -- minimum 8 characters

Reserved publisher scopes that cannot be used: snippbot, community, singularity.

Step 2: Log In

snippbot marketplace login

Credentials are stored at ~/.snippbot/marketplace_token.json with 0600 permissions.

Step 3: Publish

cd my-tool-package/
snippbot marketplace publish .

The CLI:

  1. Loads and validates singularity.json.
  2. Builds a .tar.gz archive respecting .singularityignore.
  3. Creates the package on the registry if this is the first publish.
  4. Uploads the archive and metadata.

Step 4 (Optional): Sign Your Package

For an additional layer of trust, sign your package with an Ed25519 key:

# Generate a key pair (one-time)
snippbot marketplace keygen

# Upload your public key to your publisher profile (one-time)
snippbot marketplace upload-key ~/.snippbot/singularity_signing_key.pub

# Sign the package before publishing
snippbot marketplace sign .

# Then publish as normal
snippbot marketplace publish .

Security Scanning Pipeline

After upload, every package version goes through a four-layer security scanning pipeline before it is published to the marketplace:

Layer Name Duration Description
1 Static Analysis < 30s AST analysis, pattern matching for known malicious patterns, obfuscation detection.
2 VirusTotal 1-5 min Scans the archive against 70+ antivirus engines.
3a Permission Validation < 30s Verifies declared permissions match actual code behavior.
3b Sandbox Testing 2-10 min Executes the tool in an isolated container and monitors system calls.
4 LLM Code Review 1-3 min AI-assisted semantic analysis for deceptive patterns and undeclared behavior.

Possible outcomes:

Decision Meaning
PASS Package is published immediately.
CONDITIONAL_PASS Published with a warning badge (1 high-severity finding).
MANUAL_REVIEW Queued for human review by the Singularity team.
REJECT Not published. Critical findings or failed layers.

If your package is rejected, check the scan report for specific findings and remediate them before re-publishing.


Common Patterns

API Wrapper Tool

A tool that wraps an external REST API, exposing it as a tool the agent can invoke.

api-wrapper-tool/
  singularity.json
  main.py
  requirements.txt

singularity.json:

{
  "name": "@your-name/weather-lookup",
  "version": "1.0.0",
  "type": "tool",
  "displayName": "Weather Lookup",
  "description": "Look up current weather conditions and forecasts for any city worldwide.",
  "author": { "name": "your-name" },
  "license": "MIT",
  "category": "api-tools",
  "tags": ["weather", "api", "lookup"],
  "tool": {
    "executionType": "python",
    "entryPoint": "main.py",
    "tools": [
      {
        "name": "get_weather",
        "description": "Get current weather for a city.",
        "inputSchema": {
          "type": "object",
          "properties": {
            "city": { "type": "string", "description": "City name" },
            "units": { "type": "string", "enum": ["metric", "imperial"], "default": "metric" }
          },
          "required": ["city"]
        }
      },
      {
        "name": "get_forecast",
        "description": "Get a 5-day weather forecast for a city.",
        "inputSchema": {
          "type": "object",
          "properties": {
            "city": { "type": "string", "description": "City name" },
            "days": { "type": "integer", "minimum": 1, "maximum": 5, "default": 3 }
          },
          "required": ["city"]
        }
      }
    ]
  },
  "permissions": {
    "network": {
      "hosts": ["api.openweathermap.org"],
      "reason": "Fetches weather data from the OpenWeatherMap API."
    },
    "environment": {
      "variables": ["OPENWEATHER_API_KEY"],
      "reason": "API key for authenticating with OpenWeatherMap."
    }
  }
}

main.py:

import json
import os
import sys

import httpx

API_KEY = os.environ.get("OPENWEATHER_API_KEY", "")
BASE_URL = "https://api.openweathermap.org/data/2.5"


def get_weather(city: str, units: str = "metric") -> dict:
    resp = httpx.get(
        f"{BASE_URL}/weather",
        params={"q": city, "appid": API_KEY, "units": units},
        timeout=15.0,
    )
    resp.raise_for_status()
    data = resp.json()
    return {
        "city": data["name"],
        "temperature": data["main"]["temp"],
        "feels_like": data["main"]["feels_like"],
        "humidity": data["main"]["humidity"],
        "description": data["weather"][0]["description"],
        "wind_speed": data["wind"]["speed"],
    }


def get_forecast(city: str, days: int = 3) -> dict:
    resp = httpx.get(
        f"{BASE_URL}/forecast",
        params={"q": city, "appid": API_KEY, "units": "metric", "cnt": days * 8},
        timeout=15.0,
    )
    resp.raise_for_status()
    data = resp.json()
    forecasts = []
    for item in data["list"][:days * 8:8]:
        forecasts.append({
            "date": item["dt_txt"],
            "temp": item["main"]["temp"],
            "description": item["weather"][0]["description"],
        })
    return {"city": data["city"]["name"], "forecasts": forecasts}


def main():
    data = json.loads(sys.stdin.read())
    tool = data.get("tool", "")
    params = data.get("input", {})

    handlers = {
        "get_weather": lambda: get_weather(params["city"], params.get("units", "metric")),
        "get_forecast": lambda: get_forecast(params["city"], params.get("days", 3)),
    }

    handler = handlers.get(tool)
    if handler is None:
        print(json.dumps({"error": f"Unknown tool: {tool}"}))
        sys.exit(1)

    try:
        result = handler()
        print(json.dumps(result))
    except httpx.HTTPStatusError as e:
        print(json.dumps({"error": f"API error: {e.response.status_code}"}))
        sys.exit(1)


if __name__ == "__main__":
    main()

File Processor Tool

A tool that reads files from the workspace, processes them, and writes results back.

{
  "name": "@your-name/csv-analyzer",
  "version": "1.0.0",
  "type": "tool",
  "displayName": "CSV Analyzer",
  "description": "Analyze CSV files to produce summary statistics, detect data types, and identify anomalies.",
  "author": { "name": "your-name" },
  "license": "MIT",
  "category": "data",
  "tool": {
    "executionType": "python",
    "entryPoint": "main.py",
    "tools": [
      {
        "name": "analyze_csv",
        "description": "Analyze a CSV file and return summary statistics for each column.",
        "inputSchema": {
          "type": "object",
          "properties": {
            "file_path": { "type": "string", "description": "Path to the CSV file in the workspace." },
            "output_path": { "type": "string", "description": "Path to write the analysis report." }
          },
          "required": ["file_path"]
        }
      }
    ]
  },
  "permissions": {
    "filesystem": {
      "scope": "workspace",
      "read": true,
      "write": true,
      "reason": "Reads CSV files and writes analysis reports to the workspace."
    }
  }
}

main.py:

import csv
import json
import sys
from pathlib import Path


def analyze_csv(file_path: str, output_path: str | None = None) -> dict:
    path = Path(file_path)
    if not path.exists():
        return {"error": f"File not found: {file_path}"}

    with open(path, newline="", encoding="utf-8") as f:
        reader = csv.DictReader(f)
        rows = list(reader)

    if not rows:
        return {"error": "CSV file is empty", "rows": 0}

    columns = list(rows[0].keys())
    stats = {}
    for col in columns:
        values = [r[col] for r in rows if r[col]]
        numeric = []
        for v in values:
            try:
                numeric.append(float(v))
            except ValueError:
                pass
        col_stat = {
            "total_values": len(values),
            "unique_values": len(set(values)),
            "empty_count": len(rows) - len(values),
        }
        if numeric:
            col_stat["min"] = min(numeric)
            col_stat["max"] = max(numeric)
            col_stat["mean"] = sum(numeric) / len(numeric)
            col_stat["type"] = "numeric"
        else:
            col_stat["type"] = "text"
        stats[col] = col_stat

    result = {"rows": len(rows), "columns": len(columns), "column_stats": stats}

    if output_path:
        out = Path(output_path)
        out.write_text(json.dumps(result, indent=2), encoding="utf-8")
        result["report_written_to"] = output_path

    return result


def main():
    data = json.loads(sys.stdin.read())
    params = data.get("input", {})
    result = analyze_csv(params.get("file_path", ""), params.get("output_path"))
    print(json.dumps(result))


if __name__ == "__main__":
    main()

CLI Wrapper Tool

A tool that wraps existing command-line programs, making them available to agents.

{
  "name": "@your-name/git-stats",
  "version": "1.0.0",
  "type": "tool",
  "displayName": "Git Stats",
  "description": "Retrieve git repository statistics including commit counts, contributor lists, and file change history.",
  "author": { "name": "your-name" },
  "license": "MIT",
  "category": "development",
  "tool": {
    "executionType": "python",
    "entryPoint": "main.py",
    "tools": [
      {
        "name": "git_log_summary",
        "description": "Get a summary of recent git commits.",
        "inputSchema": {
          "type": "object",
          "properties": {
            "repo_path": { "type": "string", "description": "Path to the git repository." },
            "count": { "type": "integer", "default": 10, "description": "Number of recent commits." }
          },
          "required": ["repo_path"]
        }
      },
      {
        "name": "git_contributors",
        "description": "List contributors and their commit counts.",
        "inputSchema": {
          "type": "object",
          "properties": {
            "repo_path": { "type": "string", "description": "Path to the git repository." }
          },
          "required": ["repo_path"]
        }
      }
    ]
  },
  "permissions": {
    "filesystem": {
      "scope": "workspace",
      "read": true,
      "write": false,
      "reason": "Reads the .git directory to extract repository metadata."
    },
    "shell": {
      "commands": ["git"],
      "reason": "Runs git commands to retrieve commit history and contributor data."
    }
  }
}

main.py:

import json
import subprocess
import sys
from pathlib import Path


def run_git(repo_path: str, *args: str) -> str:
    result = subprocess.run(
        ["git", "-C", repo_path, *args],
        capture_output=True,
        text=True,
        timeout=30,
    )
    if result.returncode != 0:
        raise RuntimeError(f"git error: {result.stderr.strip()}")
    return result.stdout.strip()


def git_log_summary(repo_path: str, count: int = 10) -> dict:
    if not Path(repo_path).is_dir():
        return {"error": f"Directory not found: {repo_path}"}

    log = run_git(
        repo_path, "log", f"--max-count={count}",
        "--pretty=format:%H|%an|%ae|%as|%s"
    )
    commits = []
    for line in log.splitlines():
        parts = line.split("|", 4)
        if len(parts) == 5:
            commits.append({
                "hash": parts[0][:8],
                "author": parts[1],
                "email": parts[2],
                "date": parts[3],
                "message": parts[4],
            })
    return {"commits": commits, "count": len(commits)}


def git_contributors(repo_path: str) -> dict:
    if not Path(repo_path).is_dir():
        return {"error": f"Directory not found: {repo_path}"}

    shortlog = run_git(repo_path, "shortlog", "-sne", "HEAD")
    contributors = []
    for line in shortlog.splitlines():
        line = line.strip()
        if not line:
            continue
        parts = line.split("\t", 1)
        count = int(parts[0].strip())
        name_email = parts[1] if len(parts) > 1 else ""
        contributors.append({"name": name_email, "commits": count})
    contributors.sort(key=lambda c: c["commits"], reverse=True)
    return {"contributors": contributors, "total": len(contributors)}


def main():
    data = json.loads(sys.stdin.read())
    tool = data.get("tool", "")
    params = data.get("input", {})

    handlers = {
        "git_log_summary": lambda: git_log_summary(
            params["repo_path"], params.get("count", 10)
        ),
        "git_contributors": lambda: git_contributors(params["repo_path"]),
    }

    handler = handlers.get(tool)
    if handler is None:
        print(json.dumps({"error": f"Unknown tool: {tool}"}))
        sys.exit(1)

    try:
        result = handler()
        print(json.dumps(result))
    except RuntimeError as e:
        print(json.dumps({"error": str(e)}))
        sys.exit(1)


if __name__ == "__main__":
    main()

Troubleshooting

Manifest Validation Errors

Error Cause Fix
'name' must match @publisher/package-name format Name doesn't follow @publisher/package-name with lowercase alphanumeric and hyphens. Use only lowercase letters, digits, and hyphens. Min 3, max 50 characters per segment.
'version' must be valid semver Version string is not MAJOR.MINOR.PATCH. Use strict semver: 1.0.0, 2.3.1-beta.1.
'type' must be one of: ... Invalid artifact type. For tool packages, use "type": "tool".
'description' must be at least 10 characters Description is too short. Write a meaningful description of 10-500 characters.
'permissions' is required for type 'tool' Tool packages must declare permissions. Add a "permissions": {} block, even if empty (which results in Tier 0).
'category' must be one of: ... Invalid category string. Use one of the valid categories listed in the manifest reference.

Tool Validation Errors

Error Cause Fix
Invalid execution type '...' tool.executionType is not one of the four valid types. Use python, bash, mcp_server, or http.
No entry point found for execution type '...' Neither tool.entryPoint nor any default entry point file exists. Create the entry point file or set tool.entryPoint explicitly.
Entry point '...' not found in package The specified entryPoint path does not exist in the package. Check the path is correct relative to the package root.
Tool name '...' is invalid Name does not match ^[a-z][a-z0-9_]{1,62}[a-z0-9]$. Use lowercase letters, digits, and underscores. Start with a letter. 3-64 characters.
Tool definition '...' is missing 'description' A tool in the tools[] array has no description. Add a description to every tool definition.

Permission Validation Warnings

Warning Cause Recommendation
Network host '...' is an IP address Using raw IP instead of domain name. Use domain names for clarity and audibility.
Network host '...' contains wildcard Wildcards are not supported. List each domain explicitly.
Package requests access to N network domains (N > 10) Unusually many domains. Reduce to only the domains your tool actually needs.
Unknown filesystem scope '...' Invalid scope value. Use none, workspace, workspace-readonly, or home.
Package requests home directory access (Tier 3) Home scope requires elevated trust. Consider whether workspace scope is sufficient.
Network/Shell permission is missing 'reason' field No justification provided. Add a reason string explaining why the permission is needed.
Shell command '...' is globally blocked A blocked command was declared. Remove the blocked command. It will never be granted.
Environment variable '...' is globally blocked A security-sensitive env var was declared. Remove blocked variables (LD_PRELOAD, DYLD_INSERT_LIBRARIES, etc.).

Security Scan Failures

Failure Typical Cause Resolution
Static analysis: obfuscated code detected Base64-encoded strings, exec(), eval(), dynamic imports. Refactor to use explicit, readable code. If you must use dynamic imports, add clear comments explaining why.
Permission mismatch Code accesses resources not declared in permissions (e.g., makes HTTP calls but no network.hosts declared). Declare all resources your code actually uses in the permissions block.
Sandbox test: unexpected network call Tool contacted a host not in the network.hosts list during sandbox execution. Add the host to your permissions or remove the network call.
Sandbox test: unexpected file write Tool wrote to a path outside the declared filesystem scope. Constrain file operations to the workspace directory.
LLM review: deceptive pattern README describes benign functionality but code does something different. Ensure your description and code match.

Common Runtime Errors

Error Cause Fix
Timed out creating Python venv Venv creation took longer than 60 seconds. Ensure your system has sufficient disk space and permissions.
Failed to install Python dependencies pip install failed in the isolated venv. Check your requirements.txt for typos or incompatible version constraints. Test locally: python -m venv .venv && .venv/bin/pip install -r requirements.txt.
Checksum verification failed Archive was corrupted during download. Re-download and re-install: snippbot marketplace install @publisher/package.
MCP config file not found The entryPoint for an MCP server does not point to a valid JSON config. Verify the file exists and is valid JSON. Check the path in tool.entryPoint.

Getting Help

  • Run snippbot marketplace info @publisher/package-name to see details about any published package.
  • Check installed packages: snippbot marketplace list
  • Check for security advisories: snippbot marketplace advisories
  • Check for updates: snippbot marketplace update