OmniForge

Product guides · Trusted local networks only

Writing tools

A tool is a Python or Node script with a name, a description, an optional list of packages and an optional input schema. This page sets out the exact contract between OmniForge and your script.

The contract at a glance

Behaviour
Input One JSON object on stdin. It is {} when there are no arguments. It is never put into the command line, the shell or environment variables.
Validation Arguments are checked against the tool's input schema before the script starts. If they don't match, the script never runs.
Output Everything printed to stdout, with leading and trailing whitespace trimmed, comes back as text. Print JSON if the client needs structure.
Return values Not captured. return {...} from a function produces nothing. Print it.
Diagnostics stderr is captured. In MCP results it's appended after stdout as [stderr] …, even when the run succeeds.
Success Exit code 0 and no timeout. Anything else is reported as an error (isError: true).
Time limit OMNIFORGE_MAX_SCRIPT_TIMEOUT_MS + 15 s (45 s by default), counted after packages are ready.
Size limit None. Output isn't capped (see below).
Memory limit None. OMNIFORGE_MAX_SCRIPT_MEMORY_MB isn't enforced.
Isolation None. The script is a subprocess of the app, with the app's environment, network and files.

Inputs: one JSON object on stdin

OmniForge serialises the arguments with json.dumps(…, allow_nan=False) and writes them to the script's stdin once. Read all of stdin and parse it.

Python

import json
import sys

args = json.load(sys.stdin)
name = args.get("name", "world")

Node (CommonJS)

const fs = require('node:fs');

const args = JSON.parse(fs.readFileSync(0, 'utf8'));
const name = args.name || 'world';

Schema default values are not filled in for you. Always provide fallbacks in code, as above.

Outputs: print once, print JSON

print(json.dumps({"message": f"Hello {name}"}))
console.log(JSON.stringify({ message: `Hello ${name}` }));

Errors: stderr and exit codes

Situation Exit code isError MCP text
Success 0 false stdout, then [stderr] … if anything was written to stderr
Uncaught exception or SyntaxError 1 true stdout or (no output), [stderr] <traceback>, [exit 1]
sys.exit(7) / process.exit(7) 7 true … [exit 7]
Timeout 124 true (no output), [stderr] Script exceeded 30000ms timeout, [exit 124], [timeout]
Package install failed 1 true (no output), the install error, [stderr] <same error>, [exit 1]
Runtime not found 1 true (no output), no python3 runtime available or no node runtime available, …

For example, a script that prints failure details to stderr and exits with code 7 returns this MCP text:

(no output)
[stderr] failure details
[exit 7]

Things to know:

Time limits

The environment your script runs in

Aspect Value
Command python3 <scratch>/<tool-id>.py or node <scratch>/<tool-id>.js, with no command-line arguments
Working directory OMNIFORGE_SCRATCH_ROOT (default /tmp/omniforge), shared by all tools
User UID 1000, the same as the app
Python The app's virtualenv interpreter (Python 3.12). Packages the app itself depends on, such as pydantic, httpx and jsonschema, happen to be importable. Don't rely on this: declare what you use, because app updates can change those versions.
Node The Debian-packaged Node.js in the image (docker exec omniforge node --version). CommonJS.
Package path Your tool's cache directory is placed in front of PYTHONPATH or NODE_PATH
Environment The whole application environment, including OMNIFORGE_API_KEY and anything else you passed to the container
Network Whatever the container can reach
Files Write access to /tmp, the data volume (including the SQLite database) and the package caches. Unless the root filesystem is read-only, also write access to the app code in /app, which belongs to the same UID.

[!WARNING] A tool can read and print OMNIFORGE_API_KEY. For example, import os; print(os.environ.get("OMNIFORGE_API_KEY")) returns the key to whoever called the tool. A tool can also modify omniforge.db directly. Only save code you'd be happy to run as the app itself. See Security and limitations.

Need a temporary file? Use Python's tempfile or Node's os.tmpdir(), both of which point under /tmp. Don't write next to the script: the scratch directory is shared by all tools, and <tool-id>.py is overwritten on every run.

Examples

For dependency-free scripts, complete create payloads and sample input/output, see runnable Python and Node examples (repository access required). Those examples use standard libraries only and do not install packages or access the network.

Python, no packages, with inputs: add VAT

import json
import sys
from decimal import Decimal, ROUND_HALF_UP

args = json.load(sys.stdin)
net = Decimal(str(args["net"]))
rate = Decimal(str(args.get("rate", 20)))
vat = (net * rate / 100).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
print(json.dumps({"net": str(net), "rate": str(rate), "vat": str(vat), "gross": str(net + vat)}))

Input schema:

{
  "type": "object",
  "properties": {
    "net": {"type": "number", "minimum": 0, "description": "Amount before VAT, in pounds"},
    "rate": {"type": "number", "minimum": 0, "maximum": 100, "description": "VAT rate in percent; defaults to 20"}
  },
  "required": ["net"],
  "additionalProperties": false
}

Calling it with {"net": 99.99} prints {"net": "99.99", "rate": "20", "vat": "20.00", "gross": "119.99"}.

Python with a package: next UK bank holiday

Packages: requests==2.32.5

import json
import sys
from datetime import date

import requests

args = json.load(sys.stdin)
division = args.get("division", "england-and-wales")

try:
    response = requests.get("https://www.gov.uk/bank-holidays.json", timeout=10)
    response.raise_for_status()
except requests.RequestException as exc:
    print(f"Could not fetch bank holidays: {exc}", file=sys.stderr)
    sys.exit(1)

today = date.today().isoformat()
upcoming = [e for e in response.json()[division]["events"] if e["date"] >= today]
print(json.dumps({"division": division, "next": upcoming[0] if upcoming else None}))
{
  "type": "object",
  "properties": {
    "division": {"type": "string", "enum": ["england-and-wales", "scotland", "northern-ireland"]}
  },
  "additionalProperties": false
}

Set a network timeout shorter than the script limit, as timeout=10 does here, so that a slow upstream produces a clean error instead of a killed process.

Node with a package: sort unique numbers

Packages: lodash@4.17.21

const fs = require('node:fs');
const _ = require('lodash');

const args = JSON.parse(fs.readFileSync(0, 'utf8'));
console.log(JSON.stringify({ sorted: _.sortBy(_.uniq(args.numbers)) }));
{
  "type": "object",
  "properties": {
    "numbers": {"type": "array", "items": {"type": "number"}, "maxItems": 1000}
  },
  "required": ["numbers"],
  "additionalProperties": false
}

Node, asynchronous code

Top-level await needs ES modules, which OmniForge doesn't support for installed packages. Wrap async work in a function, and make failures exit with a non-zero code:

const fs = require('node:fs');

async function main() {
  const args = JSON.parse(fs.readFileSync(0, 'utf8'));
  await new Promise(resolve => setTimeout(resolve, 100)); // your async work here
  console.log(JSON.stringify({ ok: true, received: Object.keys(args) }));
}

main().catch(error => {
  console.error(error.message);
  process.exit(1);
});

Use require, not import. NODE_PATH doesn't apply to ES-module resolution, so import _ from 'lodash' can't find installed packages. Whether .js files containing ESM syntax run at all depends on the Node version in the image.

Giving a tool inputs

The dashboard can't create or edit input schemas yet. A tool saved from the Tool studio gets an empty schema, which means "no inputs". Calling it with any argument fails with Arguments do not match inputSchema. To give a tool parameters, use one of these routes. After that, you can keep editing its code in the dashboard: saving from the studio sends the existing schema back unchanged.

Option 1: REST API

Save this as add_vat.json:

{
  "name": "add_vat",
  "description": "Add UK VAT to a net amount and return net, VAT and gross.",
  "language": "python3",
  "code": "import json\nimport sys\nfrom decimal import Decimal, ROUND_HALF_UP\n\nargs = json.load(sys.stdin)\nnet = Decimal(str(args[\"net\"]))\nrate = Decimal(str(args.get(\"rate\", 20)))\nvat = (net * rate / 100).quantize(Decimal(\"0.01\"), rounding=ROUND_HALF_UP)\nprint(json.dumps({\"net\": str(net), \"rate\": str(rate), \"vat\": str(vat), \"gross\": str(net + vat)}))\n",
  "requirements": [],
  "json_schema": {
    "type": "object",
    "properties": {
      "net": {"type": "number", "minimum": 0},
      "rate": {"type": "number", "minimum": 0, "maximum": 100}
    },
    "required": ["net"],
    "additionalProperties": false
  },
  "is_active": true
}

Then post it:

curl -s http://localhost:8000/api/tools \
  -H 'Content-Type: application/json' \
  -d @add_vat.json

The REST field is called json_schema. In MCP it's called inputSchema.

Option 2: from an AI client

Ask your assistant to call the built-in omniforge_create_tool with an inputSchema. See Built-in authoring tools (repository access required).

Changing the schema of an existing tool

PUT /api/tools/{name} replaces every field. Anything you leave out goes back to its default, so read the tool first. This needs jq:

HOST=http://localhost:8000
NAME=add_vat
SCHEMA='{"type":"object","properties":{"net":{"type":"number","minimum":0}},"required":["net"],"additionalProperties":false}'

TOOL=$(curl -s "$HOST/api/tools" | jq --arg n "$NAME" '.tools[] | select(.name == $n)')
REV=$(echo "$TOOL" | jq -r .revision)

echo "$TOOL" \
  | jq --argjson schema "$SCHEMA" '{name, description, language, code,
        requirements: (.requirements_json | fromjson), json_schema: $schema, is_active}' \
  | curl -s -X PUT "$HOST/api/tools/$NAME" \
      -H 'Content-Type: application/json' -H "If-Match: $REV" -d @-

The If-Match header makes the update fail with HTTP 412, rather than overwrite someone else's change, if the tool was edited in the meantime.

Schema rules

Names and descriptions

Things to avoid


← Home · Next: Dependencies → (repository access required)