This is an Osaurus plugin project. Use this guide to develop, test, and submit the plugin.
osaurus-xlsx/
├── Package.swift # Swift Package Manager configuration
├── Sources/
│ └── osaurus_xlsx/
│ └── Plugin.swift # Main plugin implementation
├── README.md # User-facing documentation
├── CLAUDE.md # This file (AI guidance)
└── .github/
└── workflows/
└── release.yml # CI/CD for releases
Osaurus plugins use a C ABI interface. The plugin exports a single entry point (osaurus_plugin_entry) that returns a function table with:
init()- Initialize plugin, return context pointerdestroy(ctx)- Clean up resourcesget_manifest(ctx)- Return JSON describing plugin capabilitiesinvoke(ctx, type, id, payload)- Execute a tool with JSON payloadfree_string(s)- Free strings returned to host
private struct MyTool {
let name = "my_tool" // Must match manifest id
let description = "What this tool does"
struct Args: Decodable {
let inputParam: String
let optionalParam: String?
}
func run(args: String) -> String {
// 1. Parse JSON input
guard let data = args.data(using: .utf8),
let input = try? JSONDecoder().decode(Args.self, from: data) else {
return "{\"error\": \"Invalid arguments\"}"
}
// 2. Execute tool logic
let result = processInput(input.inputParam)
// 3. Return JSON response
return "{\"result\": \"\(result)\"}"
}
}private class PluginContext {
let helloTool = HelloTool()
let myTool = MyTool() // Add your new tool
}Add the tool to the capabilities.tools array in get_manifest():
{
"id": "my_tool",
"description": "What this tool does (shown to users)",
"parameters": {
"type": "object",
"properties": {
"inputParam": {
"type": "string",
"description": "Description of this parameter"
},
"optionalParam": {
"type": "string",
"description": "Optional parameter"
}
},
"required": ["inputParam"]
},
"requirements": [],
"permission_policy": "ask"
}api.invoke = { ctxPtr, typePtr, idPtr, payloadPtr in
// ... existing code ...
if type == "tool" {
switch id {
case ctx.helloTool.name:
return makeCString(ctx.helloTool.run(args: payload))
case ctx.myTool.name:
return makeCString(ctx.myTool.run(args: payload))
default:
return makeCString("{\"error\": \"Unknown tool\"}")
}
}
return makeCString("{\"error\": \"Unknown capability\"}")
}If your plugin needs API keys or other credentials, declare them in the manifest and access them via the _secrets key in the payload.
Add a secrets array at the top level of your manifest:
{
"plugin_id": "dev.example.osaurus-xlsx",
"name": "Osaurus Xlsx",
"version": "0.1.0",
"secrets": [
{
"id": "api_key",
"label": "API Key",
"description": "Get your key from [Example](https://example.com/api)",
"required": true,
"url": "https://example.com/api"
}
],
"capabilities": { ... }
}private struct MyAPITool {
let name = "call_api"
struct Args: Decodable {
let query: String
let _secrets: [String: String]? // Secrets injected by Osaurus
}
func run(args: String) -> String {
guard let data = args.data(using: .utf8),
let input = try? JSONDecoder().decode(Args.self, from: data)
else {
return "{\"error\": \"Invalid arguments\"}"
}
// Get the API key
guard let apiKey = input._secrets?["api_key"] else {
return "{\"error\": \"API key not configured\"}"
}
// Use the API key in your request
let result = makeAPICall(apiKey: apiKey, query: input.query)
return "{\"result\": \"\(result)\"}"
}
}| Field | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Unique key (e.g., "api_key") |
label |
string | Yes | Display name in UI |
description |
string | No | Help text (supports markdown links) |
required |
boolean | Yes | Whether the secret is required |
url |
string | No | Link to get the secret |
- Users are prompted to configure secrets when installing plugins that require them
- A "Needs API Key" badge appears if required secrets are missing
- Users can edit secrets anytime via the plugin menu
- Secrets are stored securely in the macOS Keychain
When a user has a working directory selected in Agent Mode, Osaurus automatically injects the folder context into tool payloads. This allows your plugin to resolve relative file paths.
When a folder context is active, every tool invocation receives a _context object:
{
"input_path": "Screenshots/image.png",
"_context": {
"working_directory": "/Users/foo/project"
}
}private struct MyFileTool {
let name = "process_file"
struct FolderContext: Decodable {
let working_directory: String
}
struct Args: Decodable {
let path: String
let _context: FolderContext? // Folder context injected by Osaurus
}
func run(args: String) -> String {
guard let data = args.data(using: .utf8),
let input = try? JSONDecoder().decode(Args.self, from: data)
else {
return "{\"error\": \"Invalid arguments\"}"
}
// Resolve relative path using working directory
let absolutePath: String
if let workingDir = input._context?.working_directory {
absolutePath = "\(workingDir)/\(input.path)"
} else {
// No folder context - assume absolute path or return error
absolutePath = input.path
}
// SECURITY: Validate path stays within working directory
if let workingDir = input._context?.working_directory {
let resolvedPath = URL(fileURLWithPath: absolutePath).standardized.path
guard resolvedPath.hasPrefix(workingDir) else {
return "{\"error\": \"Path outside working directory\"}"
}
}
// Process the file at absolutePath...
return "{\"success\": true}"
}
}- Always validate paths stay within
working_directoryto prevent directory traversal - The LLM is instructed to use relative paths for file operations
- Reject paths that attempt to escape (e.g.,
../../../etc/passwd) - If
_contextis absent, decide whether to require it or accept absolute paths
| Field | Type | Description |
|---|---|---|
working_directory |
string | Absolute path to the user's selected folder |
MCP tools map directly to Osaurus tools:
| MCP Concept | Osaurus Equivalent |
|---|---|
| Tool name | id in manifest |
| Input schema | parameters (JSON Schema) |
| Tool handler | run() method in tool struct |
| Response | JSON string return value |
Example MCP tool conversion:
// MCP tool definition
{
"name": "get_weather",
"description": "Get weather for a location",
"inputSchema": {
"type": "object",
"properties": {
"location": { "type": "string" }
},
"required": ["location"]
}
}Becomes this Osaurus manifest entry:
{
"id": "get_weather",
"description": "Get weather for a location",
"parameters": {
"type": "object",
"properties": {
"location": { "type": "string" }
},
"required": ["location"]
},
"requirements": [],
"permission_policy": "ask"
}Wrap command-line tools using Process/subprocess:
func run(args: String) -> String {
guard let input = parseArgs(args) else {
return "{\"error\": \"Invalid arguments\"}"
}
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/some-cli")
process.arguments = [input.flag, input.value]
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8) ?? ""
if process.terminationStatus == 0 {
return "{\"output\": \"\(output.escapedForJSON)\"}"
} else {
return "{\"error\": \"Command failed: \(output.escapedForJSON)\"}"
}
} catch {
return "{\"error\": \"\(error.localizedDescription)\"}"
}
}Make HTTP requests to wrap external APIs:
func run(args: String) -> String {
guard let input = parseArgs(args) else {
return "{\"error\": \"Invalid arguments\"}"
}
// Use synchronous URLSession for plugin context
let semaphore = DispatchSemaphore(value: 0)
var result = "{\"error\": \"Request failed\"}"
let url = URL(string: "https://api.example.com/endpoint")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try? JSONEncoder().encode(input)
URLSession.shared.dataTask(with: request) { data, response, error in
defer { semaphore.signal() }
if let data = data,
let json = try? JSONSerialization.jsonObject(with: data) {
result = String(data: try! JSONSerialization.data(withJSONObject: json), encoding: .utf8)!
}
}.resume()
semaphore.wait()
return result
}swift build -c releaseExtract and validate the manifest JSON:
osaurus manifest extract .build/release/libosaurus-xlsx.dylibCheck for:
- Valid JSON structure
- All tools have unique
idvalues - Parameters use valid JSON Schema
- Version follows semver (e.g., "0.1.0")
Package and install for local testing:
# Package the plugin
osaurus tools package dev.example.osaurus-xlsx 0.1.0
# Install locally
osaurus tools install ./dev.example.osaurus-xlsx-0.1.0.zip
# Verify installation
osaurus tools verify- Open Osaurus app
- Go to Tools settings (Cmd+Shift+M → Tools)
- Verify your plugin appears
- Test each tool by asking the AI to use it
After making changes:
swift build -c release && osaurus tools package dev.example.osaurus-xlsx 0.1.0 && osaurus tools install ./dev.example.osaurus-xlsx-0.1.0.zip- Always specify
typefor each property - Use
descriptionto help the AI understand parameter purpose - Mark truly required fields in
requiredarray - Use appropriate types:
string,number,integer,boolean,array,object
{
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query text"
},
"limit": {
"type": "integer",
"description": "Maximum results to return",
"default": 10
},
"filters": {
"type": "array",
"items": { "type": "string" },
"description": "Optional filter tags"
}
},
"required": ["query"]
}Always return valid JSON, even for errors:
{"error": "Clear description of what went wrong"}For detailed errors:
{"error": "Validation failed", "details": {"field": "query", "message": "Cannot be empty"}}- Use
snake_casefor tool IDs:get_weather,search_files - Be descriptive but concise
- Prefix related tools:
github_create_issue,github_list_repos
| Policy | When to Use |
|---|---|
ask |
Default. User confirms each execution |
auto |
Safe, read-only operations |
deny |
Dangerous operations (use sparingly) |
Add to requirements array when your tool needs:
| Requirement | Use Case |
|---|---|
automation |
AppleScript, controlling other apps |
accessibility |
UI automation, input simulation |
calendar |
Reading/writing calendar events |
contacts |
Accessing contact information |
location |
Getting user's location |
disk |
Full disk access (Messages, Safari data) |
reminders |
Reading/writing reminders |
notes |
Accessing Notes app |
maps |
Controlling Maps app |
Before submitting to the Osaurus plugin registry:
- Plugin builds without warnings
-
osaurus manifest extractreturns valid JSON - All tools have clear descriptions
- Parameters use proper JSON Schema
- Error cases return valid JSON errors
- Version follows semver (X.Y.Z)
- plugin_id follows reverse-domain format (com.yourname.pluginname)
- README.md documents all tools
- Code is signed with Developer ID (for distribution)
codesign --force --options runtime --timestamp \
--sign "Developer ID Application: Your Name (TEAMID)" \
.build/release/libosaurus-xlsx.dylib- Fork the osaurus-tools repository
- Add
plugins/<your-plugin-id>.jsonwith metadata - Submit a pull request
- Check
osaurus manifest extractfor errors - Verify the dylib is properly signed
- Check Console.app for loading errors
- Ensure tool is in manifest
capabilities.toolsarray - Verify
invoke()handles the tool ID - Check tool ID matches exactly (case-sensitive)
- Validate JSON escaping in strings
- Use proper encoding for special characters
- Test with
echo '{"param":"value"}' | osaurus manifest extract ...