diff --git a/backend/Dockerfile b/backend/Dockerfile
index 67b5b81..f945286 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -18,8 +18,8 @@ RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server ./cmd/serv
# Final stage
FROM alpine:latest
-# curl for web fetching, ca-certificates for HTTPS
-RUN apk --no-cache add ca-certificates curl
+# curl for web fetching, ca-certificates for HTTPS, python3 for tool execution
+RUN apk --no-cache add ca-certificates curl python3
WORKDIR /app
diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go
index 296f18e..e8d461a 100644
--- a/backend/cmd/server/main.go
+++ b/backend/cmd/server/main.go
@@ -18,7 +18,7 @@ import (
)
// Version is set at build time via -ldflags, or defaults to dev
-var Version = "0.3.0"
+var Version = "0.4.0"
func getEnvOrDefault(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
diff --git a/backend/internal/api/routes.go b/backend/internal/api/routes.go
index 9a6f989..b6d90e4 100644
--- a/backend/internal/api/routes.go
+++ b/backend/internal/api/routes.go
@@ -66,6 +66,9 @@ func SetupRoutes(r *gin.Engine, db *sql.DB, ollamaURL string, appVersion string)
// IP-based geolocation (fallback when browser geolocation fails)
v1.GET("/location", IPGeolocationHandler())
+ // Tool execution (for Python tools)
+ v1.POST("/tools/execute", ExecuteToolHandler())
+
// Model registry routes (cached models from ollama.com)
models := v1.Group("/models")
{
diff --git a/backend/internal/api/tools.go b/backend/internal/api/tools.go
new file mode 100644
index 0000000..79a23f2
--- /dev/null
+++ b/backend/internal/api/tools.go
@@ -0,0 +1,174 @@
+package api
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "net/http"
+ "os"
+ "os/exec"
+ "time"
+
+ "github.com/gin-gonic/gin"
+)
+
+// ExecuteToolRequest represents a tool execution request
+type ExecuteToolRequest struct {
+ Language string `json:"language" binding:"required,oneof=python javascript"`
+ Code string `json:"code" binding:"required"`
+ Args map[string]interface{} `json:"args"`
+ Timeout int `json:"timeout"` // seconds, default 30
+}
+
+// ExecuteToolResponse represents the tool execution response
+type ExecuteToolResponse struct {
+ Success bool `json:"success"`
+ Result interface{} `json:"result,omitempty"`
+ Error string `json:"error,omitempty"`
+ Stdout string `json:"stdout,omitempty"`
+ Stderr string `json:"stderr,omitempty"`
+}
+
+// MaxOutputSize is the maximum size of tool output (100KB)
+const MaxOutputSize = 100 * 1024
+
+// ExecuteToolHandler handles tool execution requests
+func ExecuteToolHandler() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ var req ExecuteToolRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, ExecuteToolResponse{
+ Success: false,
+ Error: "Invalid request: " + err.Error(),
+ })
+ return
+ }
+
+ // Default timeout
+ timeout := req.Timeout
+ if timeout <= 0 || timeout > 60 {
+ timeout = 30
+ }
+
+ var resp ExecuteToolResponse
+
+ switch req.Language {
+ case "python":
+ resp = executePython(req.Code, req.Args, timeout)
+ case "javascript":
+ // JavaScript execution not supported on backend (runs in browser)
+ resp = ExecuteToolResponse{
+ Success: false,
+ Error: "JavaScript tools should be executed in the browser",
+ }
+ default:
+ resp = ExecuteToolResponse{
+ Success: false,
+ Error: "Unsupported language: " + req.Language,
+ }
+ }
+
+ c.JSON(http.StatusOK, resp)
+ }
+}
+
+// executePython executes Python code with the given arguments
+func executePython(code string, args map[string]interface{}, timeout int) ExecuteToolResponse {
+ // Create a wrapper script that reads args from stdin
+ wrapperScript := `
+import json
+import sys
+
+# Read args from stdin
+args = json.loads(sys.stdin.read())
+
+# Execute user code
+` + code
+
+ // Create temp file for the script
+ tmpFile, err := os.CreateTemp("", "tool-*.py")
+ if err != nil {
+ return ExecuteToolResponse{
+ Success: false,
+ Error: "Failed to create temp file: " + err.Error(),
+ }
+ }
+ defer os.Remove(tmpFile.Name())
+
+ if _, err := tmpFile.WriteString(wrapperScript); err != nil {
+ return ExecuteToolResponse{
+ Success: false,
+ Error: "Failed to write script: " + err.Error(),
+ }
+ }
+ tmpFile.Close()
+
+ // Marshal args to JSON for stdin
+ argsJSON, err := json.Marshal(args)
+ if err != nil {
+ return ExecuteToolResponse{
+ Success: false,
+ Error: "Failed to serialize args: " + err.Error(),
+ }
+ }
+
+ // Create context with timeout
+ ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
+ defer cancel()
+
+ // Execute Python (using exec.Command, not shell)
+ cmd := exec.CommandContext(ctx, "python3", tmpFile.Name())
+ cmd.Stdin = bytes.NewReader(argsJSON)
+
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+
+ err = cmd.Run()
+
+ // Check for timeout
+ if ctx.Err() == context.DeadlineExceeded {
+ return ExecuteToolResponse{
+ Success: false,
+ Error: "Execution timed out after " + string(rune(timeout)) + " seconds",
+ Stderr: truncateOutput(stderr.String()),
+ }
+ }
+
+ // Truncate output if needed
+ stdoutStr := truncateOutput(stdout.String())
+ stderrStr := truncateOutput(stderr.String())
+
+ if err != nil {
+ return ExecuteToolResponse{
+ Success: false,
+ Error: "Execution failed: " + err.Error(),
+ Stdout: stdoutStr,
+ Stderr: stderrStr,
+ }
+ }
+
+ // Try to parse stdout as JSON
+ var result interface{}
+ if stdoutStr != "" {
+ if err := json.Unmarshal([]byte(stdoutStr), &result); err != nil {
+ // If not valid JSON, return as string
+ result = stdoutStr
+ }
+ }
+
+ return ExecuteToolResponse{
+ Success: true,
+ Result: result,
+ Stdout: stdoutStr,
+ Stderr: stderrStr,
+ }
+}
+
+// truncateOutput truncates output to MaxOutputSize
+func truncateOutput(s string) string {
+ if len(s) > MaxOutputSize {
+ return s[:MaxOutputSize] + "\n... (output truncated)"
+ }
+ return s
+}
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 35ed48f..9b4d235 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,17 +1,22 @@
{
"name": "vessel",
- "version": "0.1.0",
+ "version": "0.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "vessel",
- "version": "0.1.0",
+ "version": "0.3.0",
"dependencies": {
+ "@codemirror/lang-javascript": "^6.2.3",
+ "@codemirror/lang-json": "^6.0.1",
+ "@codemirror/lang-python": "^6.1.7",
+ "@codemirror/theme-one-dark": "^6.1.2",
"@skeletonlabs/skeleton": "^2.10.0",
"@skeletonlabs/tw-plugin": "^0.4.0",
"@sveltejs/adapter-node": "^5.4.0",
"@types/dompurify": "^3.0.5",
+ "codemirror": "^6.0.1",
"dexie": "^4.0.10",
"dompurify": "^3.2.0",
"marked": "^15.0.0",
@@ -110,6 +115,137 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@codemirror/autocomplete": {
+ "version": "6.20.0",
+ "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz",
+ "integrity": "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.17.0",
+ "@lezer/common": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/commands": {
+ "version": "6.10.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.1.tgz",
+ "integrity": "sha512-uWDWFypNdQmz2y1LaNJzK7fL7TYKLeUAU0npEC685OKTF3KcQ2Vu3klIM78D7I6wGhktme0lh3CuQLv0ZCrD9Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.4.0",
+ "@codemirror/view": "^6.27.0",
+ "@lezer/common": "^1.1.0"
+ }
+ },
+ "node_modules/@codemirror/lang-javascript": {
+ "version": "6.2.4",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz",
+ "integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/language": "^6.6.0",
+ "@codemirror/lint": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.17.0",
+ "@lezer/common": "^1.0.0",
+ "@lezer/javascript": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/lang-json": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
+ "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@lezer/json": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/lang-python": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz",
+ "integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.3.2",
+ "@codemirror/language": "^6.8.0",
+ "@codemirror/state": "^6.0.0",
+ "@lezer/common": "^1.2.1",
+ "@lezer/python": "^1.1.4"
+ }
+ },
+ "node_modules/@codemirror/language": {
+ "version": "6.12.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.1.tgz",
+ "integrity": "sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.23.0",
+ "@lezer/common": "^1.5.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0",
+ "style-mod": "^4.0.0"
+ }
+ },
+ "node_modules/@codemirror/lint": {
+ "version": "6.9.2",
+ "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.2.tgz",
+ "integrity": "sha512-sv3DylBiIyi+xKwRCJAAsBZZZWo82shJ/RTMymLabAdtbkV5cSKwWDeCgtUq3v8flTaXS2y1kKkICuRYtUswyQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.35.0",
+ "crelt": "^1.0.5"
+ }
+ },
+ "node_modules/@codemirror/search": {
+ "version": "6.5.11",
+ "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz",
+ "integrity": "sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0",
+ "crelt": "^1.0.5"
+ }
+ },
+ "node_modules/@codemirror/state": {
+ "version": "6.5.3",
+ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.3.tgz",
+ "integrity": "sha512-MerMzJzlXogk2fxWFU1nKp36bY5orBG59HnPiz0G9nLRebWa0zXuv2siH6PLIHBvv5TH8CkQRqjBs0MlxCZu+A==",
+ "license": "MIT",
+ "dependencies": {
+ "@marijn/find-cluster-break": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/theme-one-dark": {
+ "version": "6.1.3",
+ "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz",
+ "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0",
+ "@lezer/highlight": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/view": {
+ "version": "6.39.8",
+ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.8.tgz",
+ "integrity": "sha512-1rASYd9Z/mE3tkbC9wInRlCNyCkSn+nLsiQKZhEDUUJiUfs/5FHDpCUDaQpoTIaNGeDc6/bhaEAyLmeEucEFPw==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.5.0",
+ "crelt": "^1.0.6",
+ "style-mod": "^4.1.0",
+ "w3c-keyname": "^2.2.4"
+ }
+ },
"node_modules/@csstools/color-helpers": {
"version": "5.1.0",
"dev": true,
@@ -698,6 +834,69 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@lezer/common": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.0.tgz",
+ "integrity": "sha512-PNGcolp9hr4PJdXR4ix7XtixDrClScvtSCYW3rQG106oVMOOI+jFb+0+J3mbeL/53g1Zd6s0kJzaw6Ri68GmAA==",
+ "license": "MIT"
+ },
+ "node_modules/@lezer/highlight": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
+ "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.3.0"
+ }
+ },
+ "node_modules/@lezer/javascript": {
+ "version": "1.5.4",
+ "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
+ "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.1.3",
+ "@lezer/lr": "^1.3.0"
+ }
+ },
+ "node_modules/@lezer/json": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
+ "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0"
+ }
+ },
+ "node_modules/@lezer/lr": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.5.tgz",
+ "integrity": "sha512-/YTRKP5yPPSo1xImYQk7AZZMAgap0kegzqCSYHjAL9x1AZ0ZQW+IpcEzMKagCsbTsLnVeWkxYrCNeXG8xEPrjg==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.0.0"
+ }
+ },
+ "node_modules/@lezer/python": {
+ "version": "1.1.18",
+ "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.18.tgz",
+ "integrity": "sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0"
+ }
+ },
+ "node_modules/@marijn/find-cluster-break": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
+ "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
+ "license": "MIT"
+ },
"node_modules/@napi-rs/canvas": {
"version": "0.1.88",
"license": "MIT",
@@ -2049,6 +2248,21 @@
"node": ">=6"
}
},
+ "node_modules/codemirror": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
+ "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/commands": "^6.0.0",
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/lint": "^6.0.0",
+ "@codemirror/search": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0"
+ }
+ },
"node_modules/comma-separated-tokens": {
"version": "2.0.3",
"license": "MIT",
@@ -2075,6 +2289,12 @@
"node": ">= 0.6"
}
},
+ "node_modules/crelt": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
+ "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
+ "license": "MIT"
+ },
"node_modules/css-tree": {
"version": "3.1.0",
"dev": true,
@@ -3362,6 +3582,12 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/style-mod": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
+ "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
+ "license": "MIT"
+ },
"node_modules/sucrase": {
"version": "3.35.1",
"license": "MIT",
@@ -3961,6 +4187,12 @@
}
}
},
+ "node_modules/w3c-keyname": {
+ "version": "2.2.8",
+ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
+ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
+ "license": "MIT"
+ },
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"dev": true,
diff --git a/frontend/package.json b/frontend/package.json
index fa35040..d51842f 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "vessel",
- "version": "0.3.0",
+ "version": "0.4.0",
"private": true,
"type": "module",
"scripts": {
@@ -32,7 +32,12 @@
"vitest": "^4.0.16"
},
"dependencies": {
+ "@codemirror/lang-javascript": "^6.2.3",
+ "@codemirror/lang-json": "^6.0.1",
+ "@codemirror/lang-python": "^6.1.7",
+ "@codemirror/theme-one-dark": "^6.1.2",
"@skeletonlabs/skeleton": "^2.10.0",
+ "codemirror": "^6.0.1",
"@skeletonlabs/tw-plugin": "^0.4.0",
"@sveltejs/adapter-node": "^5.4.0",
"@types/dompurify": "^3.0.5",
diff --git a/frontend/src/lib/components/tools/CodeEditor.svelte b/frontend/src/lib/components/tools/CodeEditor.svelte
new file mode 100644
index 0000000..949e0b0
--- /dev/null
+++ b/frontend/src/lib/components/tools/CodeEditor.svelte
@@ -0,0 +1,127 @@
+
+
+
+
+
diff --git a/frontend/src/lib/components/tools/ToolDocs.svelte b/frontend/src/lib/components/tools/ToolDocs.svelte
new file mode 100644
index 0000000..d6469a4
--- /dev/null
+++ b/frontend/src/lib/components/tools/ToolDocs.svelte
@@ -0,0 +1,117 @@
+
+
+{#if isOpen}
+
+
+
+ {language === 'javascript' ? 'JavaScript' : 'Python'} Tool Guide
+
+ {#if onclose}
+
+
+
+
+
+ {/if}
+
+
+
+ {#if language === 'javascript'}
+
+
+
Arguments
+
Access parameters via the args object:
+
const name = args.name;
+const count = args.count || 10;
+
+
+
+
Return Value
+
Return any JSON-serializable value:
+
return {'{'}
+ success: true,
+ data: result
+{'}'};
+
+
+
+
Async/Await
+
Full async support - use await for API calls:
+
const res = await fetch(args.url);
+const data = await res.json();
+return data;
+
+
+
+
Error Handling
+
Throw errors to signal failures:
+
if (!args.required_param) {'{'}
+ throw new Error('Missing required param');
+{'}'}
+
+ {:else}
+
+
+
Arguments
+
Access parameters via the args dict:
+
name = args.get('name')
+count = args.get('count', 10)
+
+
+
+
Return Value
+
Print JSON to stdout (import json first):
+
import json
+
+result = {'{'}'success': True, 'data': data{'}'}
+print(json.dumps(result))
+
+
+
+
Available Modules
+
Python standard library is available:
+
import json, math, re
+import hashlib, base64
+import urllib.request
+from collections import Counter
+
+
+
+
Error Handling
+
Print error JSON or raise exceptions:
+
try:
+ # risky operation
+except Exception as e:
+ print(json.dumps({'{'}'error': str(e){'}'})
+
+ {/if}
+
+
+
Tips
+
+ Tools run with a 30-second timeout
+ Large outputs are truncated at 100KB
+ Network requests are allowed
+ Use descriptive error messages for debugging
+
+
+
+
+{/if}
diff --git a/frontend/src/lib/components/tools/ToolEditor.svelte b/frontend/src/lib/components/tools/ToolEditor.svelte
index a7f953c..afff102 100644
--- a/frontend/src/lib/components/tools/ToolEditor.svelte
+++ b/frontend/src/lib/components/tools/ToolEditor.svelte
@@ -5,6 +5,10 @@
import { toolsState } from '$lib/stores';
import type { CustomTool, JSONSchema, JSONSchemaProperty, ToolImplementation } from '$lib/tools';
+ import { getTemplatesByLanguage, type ToolTemplate } from '$lib/tools';
+ import CodeEditor from './CodeEditor.svelte';
+ import ToolDocs from './ToolDocs.svelte';
+ import ToolTester from './ToolTester.svelte';
interface Props {
isOpen: boolean;
@@ -15,21 +19,62 @@
const { isOpen, editingTool = null, onClose, onSave }: Props = $props();
+ // Default code templates
+ const defaultJsCode = `// Arguments are available as \`args\` object
+// Return the result
+return { message: "Hello from custom tool!" };`;
+
+ const defaultPythonCode = `# Arguments available as \`args\` dict
+# Print JSON result to stdout
+import json
+
+result = {"message": f"Hello, {args.get('name', 'World')}!"}
+print(json.dumps(result))`;
+
// Form state
let name = $state('');
let description = $state('');
let implementation = $state('javascript');
- let code = $state('// Arguments are available as `args` object\n// Return the result\nreturn { message: "Hello from custom tool!" };');
+ let code = $state(defaultJsCode);
let endpoint = $state('');
let httpMethod = $state<'GET' | 'POST'>('POST');
let enabled = $state(true);
+ // Track previous implementation for code switching
+ let prevImplementation = $state('javascript');
+
// Parameters state (simplified - array of parameter definitions)
let parameters = $state>([]);
// Validation
let errors = $state>({});
+ // UI state
+ let showDocs = $state(false);
+ let showTemplates = $state(false);
+ let showTest = $state(false);
+
+ // Get templates for current language
+ const currentTemplates = $derived(
+ implementation === 'javascript' || implementation === 'python'
+ ? getTemplatesByLanguage(implementation)
+ : []
+ );
+
+ function applyTemplate(template: ToolTemplate): void {
+ name = template.name.toLowerCase().replace(/[^a-z0-9]+/g, '_');
+ description = template.description;
+ code = template.code;
+ // Convert parameters from template
+ parameters = Object.entries(template.parameters.properties ?? {}).map(([paramName, prop]) => ({
+ name: paramName,
+ type: prop.type,
+ description: prop.description ?? '',
+ required: template.parameters.required?.includes(paramName) ?? false
+ }));
+ showTemplates = false;
+ }
+
// Reset form when modal opens or editing tool changes
$effect(() => {
if (isOpen) {
@@ -59,13 +104,32 @@
name = '';
description = '';
implementation = 'javascript';
- code = '// Arguments are available as `args` object\n// Return the result\nreturn { message: "Hello from custom tool!" };';
+ prevImplementation = 'javascript';
+ code = defaultJsCode;
endpoint = '';
httpMethod = 'POST';
enabled = true;
parameters = [];
}
+ // Switch to default code when implementation changes (unless editing)
+ $effect(() => {
+ if (!editingTool && implementation !== prevImplementation) {
+ // Only switch code if it's still the default for the previous type
+ const isDefaultJs = code === defaultJsCode || code.trim() === '';
+ const isDefaultPy = code === defaultPythonCode;
+
+ if (isDefaultJs || isDefaultPy || code.trim() === '') {
+ if (implementation === 'python') {
+ code = defaultPythonCode;
+ } else if (implementation === 'javascript') {
+ code = defaultJsCode;
+ }
+ }
+ prevImplementation = implementation;
+ }
+ });
+
function addParameter(): void {
parameters = [...parameters, { name: '', type: 'string', description: '', required: false }];
}
@@ -93,8 +157,8 @@
newErrors.description = 'Description is required';
}
- if (implementation === 'javascript' && !code.trim()) {
- newErrors.code = 'JavaScript code is required';
+ if ((implementation === 'javascript' || implementation === 'python') && !code.trim()) {
+ newErrors.code = `${implementation === 'javascript' ? 'JavaScript' : 'Python'} code is required`;
}
if (implementation === 'http' && !endpoint.trim()) {
@@ -144,7 +208,7 @@
description: description.trim(),
parameters: buildParameterSchema(),
implementation,
- code: implementation === 'javascript' ? code : undefined,
+ code: (implementation === 'javascript' || implementation === 'python') ? code : undefined,
endpoint: implementation === 'http' ? endpoint : undefined,
httpMethod: implementation === 'http' ? httpMethod : undefined,
enabled,
@@ -290,7 +354,7 @@
Implementation
-
+
JavaScript
+
+
+ Python
+
-
- {#if implementation === 'javascript'}
+
+ {#if implementation === 'javascript' || implementation === 'python'}
-
JavaScript Code
-
- Arguments are passed as an args object. Return the result.
+
+
+ {implementation === 'javascript' ? 'JavaScript' : 'Python'} Code
+
+
+
+
+
showTemplates = !showTemplates}
+ class="flex items-center gap-1 text-xs text-blue-400 hover:text-blue-300"
+ >
+
+
+
+ Templates
+
+ {#if showTemplates && currentTemplates.length > 0}
+
+
+ {#each currentTemplates as template (template.id)}
+
applyTemplate(template)}
+ class="w-full text-left px-3 py-2 rounded hover:bg-theme-tertiary"
+ >
+ {template.name}
+ {template.description}
+
+ {/each}
+
+
+ {/if}
+
+
+
showDocs = !showDocs}
+ class="flex items-center gap-1 text-xs {showDocs ? 'text-emerald-400' : 'text-theme-muted hover:text-theme-secondary'}"
+ >
+
+
+
+ Docs
+
+
+
+
+ {#if implementation === 'javascript'}
+ Arguments are passed as an args object. Return the result.
+ {:else}
+ Arguments are available as args dict. Print JSON result to stdout.
+ {/if}
-
+
+
+
showDocs = false}
+ />
+
+
+
+
{#if errors.code}
{errors.code}
{/if}
+
+
+ showTest = !showTest}
+ class="mt-3 flex items-center gap-2 text-sm {showTest ? 'text-emerald-400' : 'text-theme-muted hover:text-theme-secondary'}"
+ >
+
+
+
+ {showTest ? 'Hide Test Panel' : 'Test Tool'}
+
+
+
+ showTest = false}
+ />
{/if}
diff --git a/frontend/src/lib/components/tools/ToolTester.svelte b/frontend/src/lib/components/tools/ToolTester.svelte
new file mode 100644
index 0000000..d0a1b8f
--- /dev/null
+++ b/frontend/src/lib/components/tools/ToolTester.svelte
@@ -0,0 +1,220 @@
+
+
+{#if isOpen}
+
+
+
+
+
+
+ Test Tool
+
+ {#if onclose}
+
+
+
+
+
+ {/if}
+
+
+
+
+
+ Input Arguments (JSON)
+
+
+
+
+
+ {#if isRunning}
+
+
+
+
+ Running...
+ {:else}
+
+
+
+ Run Test
+ {/if}
+
+
+
+ {#if testResult}
+
+
Result
+
+ {#if testResult.success}
+
+
{formatResult(testResult.result)}
+ {:else}
+
+
{testResult.error}
+ {/if}
+
+
+ {/if}
+
+
+{/if}
diff --git a/frontend/src/lib/components/tools/index.ts b/frontend/src/lib/components/tools/index.ts
index a7246ca..edadbcf 100644
--- a/frontend/src/lib/components/tools/index.ts
+++ b/frontend/src/lib/components/tools/index.ts
@@ -2,4 +2,7 @@
* Tools components exports
*/
+export { default as CodeEditor } from './CodeEditor.svelte';
+export { default as ToolDocs } from './ToolDocs.svelte';
export { default as ToolEditor } from './ToolEditor.svelte';
+export { default as ToolTester } from './ToolTester.svelte';
diff --git a/frontend/src/lib/tools/executor.ts b/frontend/src/lib/tools/executor.ts
index 1347cbe..4b21e65 100644
--- a/frontend/src/lib/tools/executor.ts
+++ b/frontend/src/lib/tools/executor.ts
@@ -41,6 +41,45 @@ async function executeJavaScriptTool(tool: CustomTool, args: Record
): Promise {
+ if (!tool.code) {
+ throw new Error('Python tool has no code');
+ }
+
+ try {
+ const response = await fetch('/api/v1/tools/execute', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ language: 'python',
+ code: tool.code,
+ args,
+ timeout: 30
+ })
+ });
+
+ if (!response.ok) {
+ throw new Error(`Backend error: ${response.status} ${response.statusText}`);
+ }
+
+ const data = await response.json();
+ if (!data.success) {
+ throw new Error(data.error || 'Python execution failed');
+ }
+
+ return data.result;
+ } catch (error) {
+ throw new Error(`Python tool error: ${error instanceof Error ? error.message : String(error)}`);
+ }
+}
+
/**
* Execute a custom HTTP tool
*/
@@ -168,6 +207,8 @@ export async function executeCustomTool(
switch (tool.implementation) {
case 'javascript':
return executeJavaScriptTool(tool, args);
+ case 'python':
+ return executePythonTool(tool, args);
case 'http':
return executeHttpTool(tool, args);
default:
diff --git a/frontend/src/lib/tools/index.ts b/frontend/src/lib/tools/index.ts
index eba6116..13896ec 100644
--- a/frontend/src/lib/tools/index.ts
+++ b/frontend/src/lib/tools/index.ts
@@ -21,3 +21,10 @@ export {
defaultToolConfig,
type ToolConfig
} from './config.js';
+export {
+ toolTemplates,
+ getTemplatesByLanguage,
+ getTemplatesByCategory,
+ getTemplateById,
+ type ToolTemplate
+} from './templates.js';
diff --git a/frontend/src/lib/tools/templates.ts b/frontend/src/lib/tools/templates.ts
new file mode 100644
index 0000000..313250d
--- /dev/null
+++ b/frontend/src/lib/tools/templates.ts
@@ -0,0 +1,352 @@
+/**
+ * Tool templates - Starter templates for custom tools
+ */
+
+import type { JSONSchema, ToolImplementation } from './types';
+
+export interface ToolTemplate {
+ id: string;
+ name: string;
+ description: string;
+ category: 'api' | 'data' | 'utility' | 'integration';
+ language: ToolImplementation;
+ code: string;
+ parameters: JSONSchema;
+}
+
+export const toolTemplates: ToolTemplate[] = [
+ // JavaScript Templates
+ {
+ id: 'js-api-fetch',
+ name: 'API Request',
+ description: 'Fetch data from an external REST API',
+ category: 'api',
+ language: 'javascript',
+ code: `// Fetch data from an API endpoint
+const response = await fetch(args.url, {
+ method: args.method || 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...(args.headers || {})
+ },
+ ...(args.body ? { body: JSON.stringify(args.body) } : {})
+});
+
+if (!response.ok) {
+ throw new Error(\`HTTP \${response.status}: \${response.statusText}\`);
+}
+
+return await response.json();`,
+ parameters: {
+ type: 'object',
+ properties: {
+ url: { type: 'string', description: 'The API endpoint URL' },
+ method: { type: 'string', description: 'HTTP method (GET, POST, etc.)' },
+ headers: { type: 'object', description: 'Additional headers' },
+ body: { type: 'object', description: 'Request body for POST/PUT' }
+ },
+ required: ['url']
+ }
+ },
+ {
+ id: 'js-json-transform',
+ name: 'JSON Transform',
+ description: 'Transform and filter JSON data',
+ category: 'data',
+ language: 'javascript',
+ code: `// Transform JSON data
+const data = args.data;
+const fields = args.fields || Object.keys(data[0] || data);
+
+// Handle both arrays and single objects
+const items = Array.isArray(data) ? data : [data];
+
+const result = items.map(item => {
+ const filtered = {};
+ for (const field of fields) {
+ if (field in item) {
+ filtered[field] = item[field];
+ }
+ }
+ return filtered;
+});
+
+return Array.isArray(data) ? result : result[0];`,
+ parameters: {
+ type: 'object',
+ properties: {
+ data: { type: 'object', description: 'JSON data to transform' },
+ fields: { type: 'array', description: 'Fields to keep (optional)' }
+ },
+ required: ['data']
+ }
+ },
+ {
+ id: 'js-string-utils',
+ name: 'String Utilities',
+ description: 'Common string manipulation operations',
+ category: 'utility',
+ language: 'javascript',
+ code: `// String manipulation utilities
+const text = args.text;
+const operation = args.operation;
+
+switch (operation) {
+ case 'uppercase':
+ return text.toUpperCase();
+ case 'lowercase':
+ return text.toLowerCase();
+ case 'capitalize':
+ return text.charAt(0).toUpperCase() + text.slice(1).toLowerCase();
+ case 'reverse':
+ return text.split('').reverse().join('');
+ case 'word_count':
+ return { count: text.split(/\\s+/).filter(w => w).length };
+ case 'char_count':
+ return { count: text.length };
+ case 'slug':
+ return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
+ default:
+ return { text, error: 'Unknown operation' };
+}`,
+ parameters: {
+ type: 'object',
+ properties: {
+ text: { type: 'string', description: 'Input text to process' },
+ operation: { type: 'string', description: 'Operation: uppercase, lowercase, capitalize, reverse, word_count, char_count, slug' }
+ },
+ required: ['text', 'operation']
+ }
+ },
+ {
+ id: 'js-date-utils',
+ name: 'Date Utilities',
+ description: 'Date formatting and calculations',
+ category: 'utility',
+ language: 'javascript',
+ code: `// Date utilities
+const date = args.date ? new Date(args.date) : new Date();
+const format = args.format || 'iso';
+
+const formatDate = (d, fmt) => {
+ const pad = n => String(n).padStart(2, '0');
+
+ switch (fmt) {
+ case 'iso':
+ return d.toISOString();
+ case 'date':
+ return d.toLocaleDateString();
+ case 'time':
+ return d.toLocaleTimeString();
+ case 'unix':
+ return Math.floor(d.getTime() / 1000);
+ case 'relative':
+ const diff = Date.now() - d.getTime();
+ const mins = Math.floor(diff / 60000);
+ if (mins < 60) return \`\${mins} minutes ago\`;
+ const hours = Math.floor(mins / 60);
+ if (hours < 24) return \`\${hours} hours ago\`;
+ return \`\${Math.floor(hours / 24)} days ago\`;
+ default:
+ return d.toISOString();
+ }
+};
+
+return {
+ formatted: formatDate(date, format),
+ timestamp: date.getTime(),
+ iso: date.toISOString()
+};`,
+ parameters: {
+ type: 'object',
+ properties: {
+ date: { type: 'string', description: 'Date string or timestamp (default: now)' },
+ format: { type: 'string', description: 'Format: iso, date, time, unix, relative' }
+ }
+ }
+ },
+
+ // Python Templates
+ {
+ id: 'py-api-fetch',
+ name: 'API Request (Python)',
+ description: 'Fetch data from an external REST API using Python',
+ category: 'api',
+ language: 'python',
+ code: `# Fetch data from an API endpoint
+import json
+import urllib.request
+import urllib.error
+
+url = args.get('url')
+method = args.get('method', 'GET')
+headers = args.get('headers', {})
+body = args.get('body')
+
+req = urllib.request.Request(url, method=method)
+req.add_header('Content-Type', 'application/json')
+for key, value in headers.items():
+ req.add_header(key, value)
+
+data = json.dumps(body).encode() if body else None
+
+try:
+ with urllib.request.urlopen(req, data=data) as response:
+ result = json.loads(response.read().decode())
+ print(json.dumps(result))
+except urllib.error.HTTPError as e:
+ print(json.dumps({"error": f"HTTP {e.code}: {e.reason}"}))`,
+ parameters: {
+ type: 'object',
+ properties: {
+ url: { type: 'string', description: 'The API endpoint URL' },
+ method: { type: 'string', description: 'HTTP method (GET, POST, etc.)' },
+ headers: { type: 'object', description: 'Additional headers' },
+ body: { type: 'object', description: 'Request body for POST/PUT' }
+ },
+ required: ['url']
+ }
+ },
+ {
+ id: 'py-data-analysis',
+ name: 'Data Analysis (Python)',
+ description: 'Basic statistical analysis of numeric data',
+ category: 'data',
+ language: 'python',
+ code: `# Basic data analysis
+import json
+import math
+
+data = args.get('data', [])
+if not data:
+ print(json.dumps({"error": "No data provided"}))
+else:
+ n = len(data)
+ total = sum(data)
+ mean = total / n
+
+ sorted_data = sorted(data)
+ mid = n // 2
+ median = sorted_data[mid] if n % 2 else (sorted_data[mid-1] + sorted_data[mid]) / 2
+
+ variance = sum((x - mean) ** 2 for x in data) / n
+ std_dev = math.sqrt(variance)
+
+ result = {
+ "count": n,
+ "sum": total,
+ "mean": round(mean, 4),
+ "median": median,
+ "min": min(data),
+ "max": max(data),
+ "std_dev": round(std_dev, 4),
+ "variance": round(variance, 4)
+ }
+ print(json.dumps(result))`,
+ parameters: {
+ type: 'object',
+ properties: {
+ data: { type: 'array', description: 'Array of numbers to analyze' }
+ },
+ required: ['data']
+ }
+ },
+ {
+ id: 'py-text-analysis',
+ name: 'Text Analysis (Python)',
+ description: 'Analyze text for word frequency, sentiment indicators',
+ category: 'data',
+ language: 'python',
+ code: `# Text analysis
+import json
+import re
+from collections import Counter
+
+text = args.get('text', '')
+top_n = args.get('top_n', 10)
+
+# Tokenize and count
+words = re.findall(r'\\b\\w+\\b', text.lower())
+word_freq = Counter(words)
+
+# Basic stats
+sentences = re.split(r'[.!?]+', text)
+sentences = [s.strip() for s in sentences if s.strip()]
+
+result = {
+ "word_count": len(words),
+ "unique_words": len(word_freq),
+ "sentence_count": len(sentences),
+ "avg_word_length": round(sum(len(w) for w in words) / len(words), 2) if words else 0,
+ "top_words": dict(word_freq.most_common(top_n)),
+ "char_count": len(text),
+ "char_count_no_spaces": len(text.replace(' ', ''))
+}
+print(json.dumps(result))`,
+ parameters: {
+ type: 'object',
+ properties: {
+ text: { type: 'string', description: 'Text to analyze' },
+ top_n: { type: 'number', description: 'Number of top words to return (default: 10)' }
+ },
+ required: ['text']
+ }
+ },
+ {
+ id: 'py-hash-encode',
+ name: 'Hash & Encode (Python)',
+ description: 'Hash strings and encode/decode base64',
+ category: 'utility',
+ language: 'python',
+ code: `# Hash and encoding utilities
+import json
+import hashlib
+import base64
+
+text = args.get('text', '')
+operation = args.get('operation', 'md5')
+
+result = {}
+
+if operation == 'md5':
+ result['hash'] = hashlib.md5(text.encode()).hexdigest()
+elif operation == 'sha256':
+ result['hash'] = hashlib.sha256(text.encode()).hexdigest()
+elif operation == 'sha512':
+ result['hash'] = hashlib.sha512(text.encode()).hexdigest()
+elif operation == 'base64_encode':
+ result['encoded'] = base64.b64encode(text.encode()).decode()
+elif operation == 'base64_decode':
+ try:
+ result['decoded'] = base64.b64decode(text.encode()).decode()
+ except Exception as e:
+ result['error'] = str(e)
+else:
+ result['error'] = f'Unknown operation: {operation}'
+
+result['operation'] = operation
+result['input_length'] = len(text)
+
+print(json.dumps(result))`,
+ parameters: {
+ type: 'object',
+ properties: {
+ text: { type: 'string', description: 'Text to process' },
+ operation: { type: 'string', description: 'Operation: md5, sha256, sha512, base64_encode, base64_decode' }
+ },
+ required: ['text', 'operation']
+ }
+ }
+];
+
+export function getTemplatesByLanguage(language: ToolImplementation): ToolTemplate[] {
+ return toolTemplates.filter(t => t.language === language);
+}
+
+export function getTemplatesByCategory(category: ToolTemplate['category']): ToolTemplate[] {
+ return toolTemplates.filter(t => t.category === category);
+}
+
+export function getTemplateById(id: string): ToolTemplate | undefined {
+ return toolTemplates.find(t => t.id === id);
+}
diff --git a/frontend/src/lib/tools/types.ts b/frontend/src/lib/tools/types.ts
index 6bf15de..ba38f69 100644
--- a/frontend/src/lib/tools/types.ts
+++ b/frontend/src/lib/tools/types.ts
@@ -54,7 +54,7 @@ export interface ToolResult {
}
/** Tool implementation type */
-export type ToolImplementation = 'builtin' | 'javascript' | 'http';
+export type ToolImplementation = 'builtin' | 'javascript' | 'python' | 'http';
/** Custom tool configuration */
export interface CustomTool {
@@ -63,7 +63,7 @@ export interface CustomTool {
description: string;
parameters: JSONSchema;
implementation: ToolImplementation;
- /** JavaScript code for 'javascript' implementation */
+ /** Code for 'javascript' or 'python' implementation */
code?: string;
/** HTTP endpoint for 'http' implementation */
endpoint?: string;