Local Tools Loading

Load your own tools.py or –tools file safely — opt-in with PRAISONAI_ALLOW_LOCAL_TOOLS=true

Point PraisonAI at your own tools.py, tools/ folder, or explicit --tools /path/to/file.py — after you flip the opt-in with --allow-local-tools (per-invocation) or PRAISONAI_ALLOW_LOCAL_TOOLS=true (shell-scoped).

python
from praisonaiagents import Agent

agent = Agent(
    name="Notes assistant",
    instructions="Answer questions using the user's notes.",
    tools=["read_notes"],
)
agent.start("What did I write about caching?")

The user enables local tools and asks a question; PraisonAI loads your module and exposes functions to the agent.

Diagram

Local Tools Loading

env set

env unset

🔑 PRAISONAI_ALLOW_LOCAL_TOOLS=true

🛡️ _safe_loader

✅ tools loaded

❌ refusal logged

Quick Start

  1. Add a tools.py next to your script

    Expose a plain Python function in tools.py:

    python
    # tools.py
    def read_notes(topic: str) -> str:
        """Return the user's notes on a topic."""
        return open(f"notes/{topic}.md").read()

    Then use it from an Agent:

    python
    # app.py
    from praisonaiagents import Agent
    
    agent = Agent(
        name="Notes assistant",
        instructions="Answer questions using the user's notes.",
        tools=["read_notes"],
    )
    
    agent.start("What did I write about caching?")

    Run with the opt-in env var:

    bash
    PRAISONAI_ALLOW_LOCAL_TOOLS=true python app.py
  2. Use an explicit path from the CLI

    Pass an absolute path with --tools — useful for tools files outside your project directory. Use the --allow-local-tools flag as a per-invocation alternative to the env var:

    bash
    praisonai-code run \
      --allow-local-tools \
      --tools /absolute/path/to/tools.py \
      "Summarise my notes on caching"

    Explicit user-provided --tools paths bypass the CWD boundary check (allow_outside_cwd=True), so absolute paths outside the repo work under the opt-in flag. CWD-derived tools.py / tools/ scanning stays strict against .. traversal.


How It Works

Diagram
PRAISONAI_ALLOW_LOCAL_TOOLS_safe_loaderCLI/AgentUserPRAISONAI_ALLOW_LOCAL_TOOLS_safe_loaderCLI/AgentUser“Refusing to exec {module_path}: set PRAISONAI_ALLOW_LOCAL_TOOLS=true to enable.”alt[env “true” (via –allow-local-tools or export)][env unset]start / run (–allow-local-tools sets env var for this run)load_user_module(path)check env varallowedresolve path, check CWD boundarymodule loaded → tools readynot setNone (warning logged)

The loader checks two conditions before executing your file:

  1. PRAISONAI_ALLOW_LOCAL_TOOLS must be set to true (case-insensitive). If not, it logs:
  2. > Refusing to exec {module_path}: set PRAISONAI_ALLOW_LOCAL_TOOLS=true to enable.
  1. The resolved path must be inside the current working directory (unless you pass an explicit --tools argument). If the path escapes the CWD boundary, it logs:
  2. > Refusing to exec {path}: outside working directory.

CLI --tools file.py also warns via stdout. When you pass a file path to --tools, --rewrite-tools, --expand-tools, or research --tools, the CLI additionally prints a yellow warning to stdout if the load produced zero functions:

Warning: No tools loaded from {path} (module has no public functions, or local tools loading is disabled — set PRAISONAI_ALLOW_LOCAL_TOOLS=true to enable).

The dual-cause phrasing is deliberate (PR #2935) — the same empty result covers both “env-var unset” and “module has no public functions after functions_only=True, skip_private=True filtering”. When the cause is the unset env var, pass --allow-local-tools on the praisonai run invocation instead of exporting the variable. See Security Environment Variables for the full message table.


Concurrent loads stay isolated

Two agents building at the same time — say, one per tenant in praisonai serve — each get a distinct module object. The loader registers every load under a unique sys.modules key of the form praisonai_userload::{name}::{path}::{uuid}, then drops the entry once your file has finished executing — whether the import succeeded or raised. Nothing else in the process can see or clobber it. If your file raises during import, only that load is discarded; other tenants’ live modules stay intact.

This means concurrent loads of the same tools.py from two Agent builds never share a sys.modules slot, a failed exec cannot pop another live load’s module, and long-lived processes (a TUI that rebuilds its resolver, a praisonai serve worker) don’t accumulate one sys.modules entry per load.

Diagram
Tenant B buildsys.modules_safe_loaderTenant A buildTenant B buildsys.modules_safe_loaderTenant A buildpar[Two tenants build at the same time]module_a is not module_b, module.__name__ differs, no clobberload_user_module(tools.py, name=”tools”)register praisonai_userload::tools::…::<uuidA>exec_module (Tenant A)drop keymodule_aload_user_module(tools.py, name=”tools”)register praisonai_userload::tools::…::<uuidB>exec_module (Tenant B)drop keymodule_b

Server import is now side-effect-free

As of PR #4261, import praisonai.api.call no longer runs ./tools.py — even when PRAISONAI_ALLOW_LOCAL_TOOLS=true. Only the server entry point triggers the load:

  • praisonai call (the CLI verb), or
  • build_call_app(load_local_tools=True) when embedding the call server programmatically.

The shared tools registry is reset and rebuilt per load (tools.clear() before each rebuild), so repeated build_call_app(load_local_tools=True) calls in the same process do not stack duplicate tool definitions — they used to append forever, sending the same tool multiple times in every realtime session.update.

See Call → Side-effect-free import + build_call_app() for the call-server embedding details.


Zero-config with .praisonai/tools/

Project-local .praisonai/tools/*.py are auto-loaded on praisonai run without any --tools flag, gated by the same opt-in. Discovery walks user-global ~/.praisonai/tools/ plus the project walk-up to the git root, and each public function is exposed as <module>.<function>.

bash
# Either — per-invocation flag
praisonai run --allow-local-tools "use the greet tool to greet Ada"
# Or — shell-scoped env var
PRAISONAI_ALLOW_LOCAL_TOOLS=true praisonai run "use the greet tool to greet Ada"

See Project-Local Tools for the full discovery rules, precedence, and @tool vs plain-callable behaviour.


Discovery Order

When you reference a tool by name (e.g. tools=["read_notes"]), PraisonAI checks four places in this fixed order — first match wins:

  1. Local tools.py — backward compatibility, custom tools, custom variables
  2. praisonaiagents.tools.TOOL_MAPPINGS — built-in SDK tools
  3. praisonai-tools package — external tools (optional install)
  4. Tool registry (plugins via entry_points) — third-party plugin sources

See Tool Discovery Order for the full breakdown.

LocalManagedAgent uses this same discovery order for its tools=[...] field. Tool names you drop in tools.py are picked up by both Agent(tools=[...]) and LocalManagedAgent(config=LocalManagedConfig(tools=[...])). See Local Managed Agents.


tools.py vs tools/ Folder

When an explicit tools.py is bound via --tools (or found at the CWD root), it takes priority over a sibling tools/ folder. The resolver honours the bound path, so an explicit --tools tools.py no longer silently loses to a sibling tools/ directory. CWD-derived scans still enforce ..-traversal safety.


--tools Outside Your Project

Only explicit user-provided paths — those you pass via --tools /path/to/file.py — opt out of the CWD boundary check. Anything the framework derives (recipe metadata, API-supplied names) stays strict. From the _safe_loader docstring:

“When True, skip the CWD boundary check. Only pass this for paths the user provided explicitly (e.g. a --tools CLI argument), never for API/network-derived paths.”


Best Practices

Keep tools.py at the top of your project

Keep tools.py at the top of your project — the CWD boundary check will find it without any flag.

Only set the env var in trusted shells

Only set PRAISONAI_ALLOW_LOCAL_TOOLS=true in trusted shells; a bare pip install will execute the file you point at.

Use absolute paths for tools outside your project

For absolute paths outside your project, pass the full path via --tools — the resolver will honour it under the opt-in flag.


Related