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:
- Validates the package structure, entry points, and permission declarations.
- Assigns a permission tier (0-4) based on the declared permissions.
- Prompts the user for permission grants through the PermissionDialog UI.
- Registers the tool(s) in the SkillsStore so agents can discover and invoke them.
- 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:
- On install, a
.venvis created inside the package directory. - Dependencies from
requirements.txt(ortool.pythonDependencies) are pip-installed into the venv. - On invocation, the tool's
main.pyis executed with the venv's Python interpreter. - 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:
- Assign a permission tier that determines resource limits.
- Display a permission dialog to the user on install.
- Enforce runtime restrictions during tool execution.
- 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_PRELOADLD_LIBRARY_PATHDYLD_INSERT_LIBRARIESDYLD_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.entryPointexplicitly to avoid ambiguity. - For Python tools, prefer
main.pyin the package root. - For MCP servers, prefer
mcp.jsonin 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:
- Manifest validation (required fields, field formats, semver check).
- Tool-specific validation (execution type, entry point existence, tool name rules).
- Permission declaration validation (scope values, host formats, blocked commands).
- Package build (creates the
.tar.gzarchive and computes the SHA-256 checksum). - Reports warnings (e.g., missing
reasonfields, 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:
- Loads and validates
singularity.json. - Builds a
.tar.gzarchive respecting.singularityignore. - Creates the package on the registry if this is the first publish.
- 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-nameto 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