Skip to main content

toolfuncs

toolfuncs turns typed Python functions into tools that have matching Python and command-line interfaces.

A toolfunc exposes typed functions through one module-level App. It can be an executable Python script or an explicitly opted-in installed package console script. Its lowercase kebab-case command name determines the Python facade name by replacing hyphens with underscores. PATH controls discovery and lookup in both cases; ordinary installed commands do not become toolfuncs automatically.

Package a tool

Use ordinary project metadata with matching console-script and toolfuncs entry points:

[project]
name = "agent-apps"
version = "0.1.0"
dependencies = ["toolfuncs"]

[project.scripts]
agent-apps = "agent_apps.tools:app"

[project.entry-points.toolfuncs]
agent-apps = "agent_apps.tools:app"

Define the interface once in agent_apps/tools.py:

"""Manage agent application conversations."""

import toolfuncs.sdk as toolsdk

app = toolsdk.App()


@app.command
def describe(conversation: str) -> dict[str, str]:
    """Describe one conversation.

    Parameters
    ----------
    conversation:
        Conversation identifier to inspect.
    """
    return {"conversation": conversation}

Install with uv-launch install agent-apps "agent-apps @ file:///absolute/path/to/project" for managed launches, or with a normal package installer for an unmanaged command. The installer generates the console script; no separate handwritten executable, self-dependent PEP 723 script, custom backend, or registration command is needed. agent-apps describe example calls the same decorated function exposed as toolfuncs.agent_apps.describe("example"). Direct Python lookup returns the real agent_apps.tools module and native objects/exceptions.

Discovery reads the installed distribution's entry points, file ownership, and module docstring without importing or executing the target or preparing dependencies. It validates that the effective PATH launcher actually calls the declared module:app. Standard Python launchers, symlinks, installer-generated /bin/sh wrappers for long interpreter paths, and uv-launch managed commands are supported. The package must include inspectable Python source in its installation; arbitrary shell launchers and editable import-hook layouts are not inferred. The first docstring line is the static description.

Direct Python loading installs the owning distribution into the caller's interpreter if needed, using its recorded exact Git origin, other direct origin, or published name/version. It then imports the actual module. This preserves native Python semantics rather than opening an RPC proxy into the tool environment. Use a suitable interpreter when tools have incompatible dependency requirements. toolfuncs doctor /path/to/agent-apps validates the installed module's registered commands and generated help. import_path retains its existing local-script contract and does not import package console wrappers.

Write a toolfunc

Create an extensionless file named weather-report:

#!/usr/bin/env toolfuncs
# /// script
# requires-python = ">=3.11"
# dependencies = ["weather-client"]
# [tool.toolfuncs]
# description = "Read current weather conditions."
# cli_name = "weather-report"
# python_name = "weather_report"
# ///

import toolfuncs.sdk as toolsdk
import weather_client

app = toolsdk.App()


@app.command
def current(city: str) -> dict[str, object]:
    """Read current conditions for one city.

    Parameters
    ----------
    city:
        City whose current conditions should be read.
    """

    return weather_client.current(city)

Make it executable:

chmod +x weather-report

No registration operation is required. Move it to an existing PATH directory only when it should be globally discoverable:

mv weather-report ~/.local/bin/

Call it

A known source can be run explicitly without adding it to PATH:

$ toolfuncs ./weather-report current London
{"city": "London", "temperature": 18}

When the source is on PATH, its filename is also the CLI:

$ weather-report current London
{"city": "London", "temperature": 18}

A known source is importable by path:

import toolfuncs as tools

weather_report = tools.import_path("./weather-report")
conditions = weather_report.current("London")

When the source is on PATH, the same file is also dynamically importable by name in a Python process with that PATH:

import toolfuncs as tools

conditions = tools.weather_report.current("London")

import_path() also works for an ordinary local Python script whose path is already known:

import toolfuncs as tools

module = tools.import_path("scripts/prepare_data.py")

It accepts one ordinary .py file or extensionless script and derives the module name from its filename. An extensionless lowercase kebab-case filename is mapped to its snake-case Python name. It does not require a shebang, executable bit, [tool.toolfuncs] metadata, or App, and it does not import packages, projects, distributions, or URLs. When an optional PEP 723 block exists, the default installer installs all declared requirements together into the running Python environment before import. A caller can replace that behavior with one callable that accepts list[str] and returns None:

module = tools.import_path(
    "scripts/prepare_data.py",
    dependency_installer=my_installer,
)

Python dependency preparation uses uv-launch: it reuses satisfying installed distributions and adds missing packages to the current interpreter. Conflicts fail before installation instead of replacing existing packages. Background resolution refresh never mutates the active interpreter. Repeated imports retain their actual module objects; a new kernel does not itself reset an existing virtual environment.

Direct Python calls return ordinary Python objects and raise the original exceptions. Dynamic tool attributes and toolfuncs.import_path() prepare declared requirements in the running Python environment before import. CLI calls use Cyclopts to parse annotated values. Interactive terminals receive Rich help, errors, and Python-object result rendering; pipes and other non-terminal destinations receive plain help and errors plus strict JSON results through pydantic-core.

Set TOOLFUNCS_OUTPUT=terminal or TOOLFUNCS_OUTPUT=machine before invoking a tool to force either presentation. The default auto mode decides independently for stdout and stderr, so redirecting a result preserves machine-readable stdout without changing an attached terminal's error presentation. Explicit Cyclopts help_formatter, error_formatter, and result_action settings remain authoritative for tools with specialized output protocols. Help and version are framework control operations, so their internal return values bypass the command result action; a registered command that returns None remains an ordinary result and renders as None or null according to the selected mode.

Function commands registered with @app.command inherit these output policies from their parent app. Set a policy on @app.command(...) to override it for that command. Registering an existing App preserves that app's own configuration.

Source contract

A toolfunc source must:

  1. be one executable file;
  2. have an extensionless lowercase ASCII kebab-case filename;
  3. start with one of the two exact toolfuncs shebangs described below;
  4. contain exactly one PEP 723 script block with dependencies, a one-line description, and required cli_name and python_name documentation that exactly matches the filename-derived identities;
  5. define a module-level app = toolsdk.App() after import toolfuncs.sdk as toolsdk;
  6. give the module and every registered operation a docstring, annotate every operation parameter and return value, and provide effective Cyclopts help for every visible CLI argument;
  7. register each CLI-callable function explicitly with @app.command.

The PEP 723 dependency list contains the packages needed in addition to the running toolfuncs environment. Listing toolfuncs itself is accepted but normally redundant because the shebang has already started the toolfuncs runtime before dependencies are prepared.

Descriptions are static by default. A tool whose available domain surface changes at runtime may opt into a last-known catalog description with dynamic = true:

[tool.toolfuncs]
description = "Use connected services."
dynamic = true
cli_name = "connected-services"
python_name = "connected_services"

After refreshing its own state, the tool publishes one current line without changing its source:

toolsdk.publish_description(
    "connected-services",
    "Use connected services: Gmail, Google Drive, and Slack.",
)

Toolfuncs stores the line at ${XDG_STATE_HOME:-~/.local/state}/toolfuncs/descriptions/connected-services. Missing or invalid state uses the required static description. The CLI and Python identities always remain static.

Git requirements may use full commits, branches, tags, abbreviated hashes, or the default branch. uv-launch pins each cached resolution to exact commits and checks for updates in the background. The ordinary #!/usr/bin/env toolfuncs shebang is sufficient. The older --allow-floating-vcs shebang is accepted but no longer changes policy. Adjacent uv lock --script files are not consumed; uv-launch owns the cached resolutions.

Only functions registered on app become CLI commands. Imported functions and __all__ do not define the command surface. Function command and option spelling follows Cyclopts' Python-to-kebab-case projection.

No if __name__ == "__main__": block is required. The operating system passes the source path to the shebang interpreter, and toolfuncs imports the source under its declared Python name before invoking its module-level app. Consequently, weather-report ... is the supported CLI while python weather-report ... merely defines the module and exits, or fails if its dependencies are not already installed.

Package-backed implementations

A toolfunc remains one script even when most of its implementation lives in a package:

#!/usr/bin/env toolfuncs
# /// script
# requires-python = ">=3.11"
# dependencies = ["my-large-package"]
# [tool.toolfuncs]
# description = "Run the package's agent operation."
# cli_name = "package-operation"
# python_name = "package_operation"
# ///

from my_large_package.agent_tool import app, perform_operation

The imported app and functions are the original Python objects. Toolfuncs does not inspect the package's project layout, metadata, or import structure.

Discovery and precedence

Toolfuncs reads the current process's PATH from left to right. It considers only executable files and symlinks, reads their first line, and parses PEP 723 metadata only when either exact toolfuncs shebang matches.

The first executable filename claims a command name even when it is not a toolfunc. Therefore an ordinary executable earlier on PATH hides a same-named toolfunc later on PATH, matching what the shell actually executes. Repeated directories are scanned once, inaccessible directories are skipped, and discovered records are sorted by CLI name.

toolfuncs list returns one JSON object with tools and invalid_tools. Valid records contain cli, python, path, and the effective description; python is the complete dynamic facade name, such as toolfuncs.weather_report. For dynamic = true, discovery reads the optional one-line state file and otherwise uses the static description. An opted-in but invalid tool is reported with its path and validation error without hiding unrelated valid tools; the command also prints a concise warning to stderr and exits successfully. Listing never imports a tool, prepares its dependencies, executes its source, or accesses the network.

Validate a tool while authoring

Run the doctor against one explicit source before installing or committing it:

toolfuncs doctor ./weather-report

The doctor validates the static source contract, prepares the declared dependencies, loads the source, and inspects the actual Cyclopts command graph. It requires a module docstring, at least one registered operation, a docstring and complete annotations for every operation, effective help text for every visible parsed CLI argument, and successful root and command help generation. Aliases are inspected once through their underlying command.

The result is always one strict-JSON tagged union. A valid source exits zero:

{"status": "success", "path": "/path/to/weather-report", "commands": ["current"]}

Expected authoring failures return status: "error", list every independently detectable operation error, and exit nonzero without using an exception as the result:

{"status": "error", "path": "/path/to/weather-report", "errors": ["current: CLI argument '--city' must have help text"]}

Loading is intentional: the registered command graph and Cyclopts' effective parameter help can be constructed dynamically and should not be approximated with a second source parser. The CLI doctor prepares an isolated uv-launch runtime; calling the Python doctor directly uses the current interpreter.

Install the runtime

uvx --from "toolfuncs>=0.12" toolfuncs setup

Setup installs uv-launch in a stable uv tool environment, prepares managed toolfuncs and toolfuncs-agent-hook commands, and writes small forwarding wrappers at ~/.local/bin/toolfuncs and ~/.agents/hooks/toolfuncs/hook. These wrappers no longer invoke uvx on each call. The destination must already be on PATH; setup is idempotent, refuses to overwrite unmanaged commands, and does not modify shell profiles.

Both shebang invocation and toolfuncs SOURCE delegate requirements to uv-launch before executing the source in an immutable environment. First use waits for installation. Warm calls reuse an existing environment and check for dependency updates in the background. Source edits run immediately; changed requirements or interpreter constraints select or prepare a matching environment before execution. Failed background updates leave the last working environment available. toolfuncs doctor SOURCE uses the same isolated preparation.

The managed hook uses the toolfuncs[hooks] environment and is registered at user scope for Codex and Claude Code through typed-agent-hooks. Running setup again reconciles those registrations, so the managed launcher remains the configured executable even when provider configuration changes.

Setup does not discover, copy, register, link, synchronize, or remove tools. There is no dedicated tools directory, registry, manifest, generated shim, project scope, user scope, or sync command.

Management commands

toolfuncs SOURCE [ARGS...]
toolfuncs doctor SOURCE
toolfuncs list
toolfuncs setup [--bin-dir DIRECTORY]

toolfuncs SOURCE [ARGS...] runs a known source directly. This is the explicit-source interface, not a name dispatcher: toolfuncs weather-report resolves weather-report as a source path rather than searching PATH for that tool name. A toolfunc on PATH can instead be invoked directly by its own filename.

Agent integration

The installed hook runs at initial session start and at the session-start event emitted after compaction. It discovers the effective tools directly from that harness process's PATH, then adds a compact catalog such as:

Available toolfuncs:
- `weather_report`: Read current weather conditions.
  CLI: `weather-report --help`
  Python: `import toolfuncs as tools; help(tools.weather_report)`

The hook reads executable headers and PEP 723 metadata, plus the optional one-line state file for tools declaring dynamic = true. It advertises valid tools and lists invalid tool paths and errors separately; one invalid tool does not suppress the others. It does not import tools, resolve their dependencies, execute them, or access the network. The hook itself is harness infrastructure, not a toolfunc, and therefore does not carry the toolfuncs shebang or appear in the catalog.

Development

uv sync
uv run pytest
uv run ruff format --check .
uv run ruff check .
uv run basedpyright

The exact guarantees and non-goals are recorded in the contract.

Releasing

The GitHub Release is the release control point. Set [project].version, merge and push that commit, then publish a GitHub Release whose tag is v<version>. The self-hosted release workflow verifies that the tag and package version match, builds the wheel and source distribution, and publishes them to PyPI through Trusted Publishing.

Metadata

Release files for toolfuncs 0.12.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for toolfuncs 0.12.0
File Size Uploaded
toolfuncs-0.12.0.tar.gz 81.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for toolfuncs 0.12.0
File Interpreter ABI Platform
toolfuncs-0.12.0-py3-none-any.whl Python 3 none any Details

Total release size: 113.3 kB

Release files / toolfuncs-0.12.0.tar.gz

Download URL toolfuncs-0.12.0.tar.gz
Size 81.9 kB
Tags Source
SHA-256 checksum
How to use checksums
dba814d0652d781003869b2c2b18d40cde66ec16a9478bf9888baff113eb6084
BLAKE2b-256 checksum
How to use checksums
c68418ded4b3b3e8ab8dfc9c4fa32842fc2941e45da31c4be4361a4a0a9924d4
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 28, 2026.

Transparency log

Release files / toolfuncs-0.12.0-py3-none-any.whl

Download URL toolfuncs-0.12.0-py3-none-any.whl
Size 31.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
aa5a6589502f4f310aff06bc8782cc1230c40a68325051ec518d0855b760cc1c
BLAKE2b-256 checksum
How to use checksums
f3f2a4f5fb2a1c21bca661faa3f713e6c06a4c2aaaee4189f57d9a7461f678c3
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 28, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.12.0 This release

2 release files

0.9.0

2 release files

0.8.0

2 release files

0.7.0

2 release files

0.6.0

2 release files

0.5.0

2 release files

0.4.0

2 release files

0.3.4

2 release files

0.3.3

2 release files

0.3.2

2 release files

0.3.1

2 release files

0.3.0

2 release files

0.1.4

2 release files

0.1.3

2 release files

0.1.2

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page