Functualize
Drop-in CLI framework for Python — auto-discovery, dependency injection, layered config, workflow graphs, and a plugin ecosystem.
A reusable Python CLI framework with auto-discovery, structured execution context, layered configuration, workflow graphs, and a plugin ecosystem.
Why Functualize?
Most CLI frameworks give you argument parsing and stop there. Functualize provides the full application lifecycle:
- No boilerplate discovery — drop a file in a directory and it becomes a command
- Dependency injection — declare what your job needs via type annotations, the framework wires it
- Config without ceremony — layered resolution means the same job works locally, in CI, and in production
- Workflow orchestration — DAGs with conditional edges and gates, not just sequential scripts
- Pluggable everything — swap state backends, add adapters (HTTP, Lambda, MCP), extend via entry points
If you're building internal tooling, deployment pipelines, or any multi-step automation that outgrows a shell script, Functualize gives you structure without locking you into a monolith.
Features
- Auto-discovery — Drop job files into a directory and they're automatically registered as CLI commands. Six configurable filters control what qualifies.
- Job Groups —
JOB_GROUPorganizes commands into hierarchies (func infra deploy).GroupOptionsdeclare flags shared by every job under a group. @jobdecorator — Declare metadata, visibility, dependencies, caching, guards, and execution policy on any job function.- Structured RunContext — Capability-based execution:
Log,Invoke,Prompt,Perf,State, plusFromJobfor declarative dependency injection andFromStepfor binding a gate tool to an earlier step's result. - Layered Configuration — Resolution chain with preset strategies (classic, twelve-factor, env-only, remote-first) and
.envfile support. - Declarative Job Config — Pydantic models drive CLI options, config resolution, and TUI form fields.
- Workflow Graphs — DAGs with
Step(func),Gate(name, awaits=Model, strategy=...), andEdge. Gates block for human or AI input;--prompt-gatesresolves them inline,--scope-idresumes blocked scopes. - Domain SDK Architecture — Pluggable capability domains (state, AI, tasks, interactivity) with swappable provider backends.
- Plugin System — Extend via Python entry points: lifecycle hooks, CLI commands, dynamic jobs, adapter plugins, and format providers.
- Built-in commands —
func builtin parallel(concurrent jobs),func builtin history(run log),func builtin env(config as env vars),func builtin shell-init(shell completions),func builtin workflow(inspect and resume gates). - Pinned exit codes — Stable, documented codes:
0success ·1job raised ·2usage/config error ·3refused pre-flight ·4stale check ·5blocked awaiting gate input. - Standalone Mode — Run single-file jobs with
func file.py function, or make them self-executing with PEP 723 shebang scripts (#!/usr/bin/env -S func). - Inline TUI — Bare
funcopens a smart command shell under your prompt: SmartBar readiness colors, autocomplete, and config panels showing where every value comes from (viafunctualize[cli]). - Scaffold Generator — Bootstrap new projects, jobs, plugins, and TUI screens with
func builtin scaffold. - Testing Utilities —
TestRunContext,CapturingLog,MockInvoke, and other test doubles for unit testing jobs in isolation.
Installation
Install the func CLI globally:
# With uv (recommended — isolated install, auto-manages PATH)
uv tool install "functualize[cli]"
# Or with pipx
pipx install "functualize[cli]"
# Or with pip (into current environment)
pip install "functualize[cli]"
No Python on the machine? Download the standalone binary — one executable with Python and every first-party plugin already inside it. Its first run needs no network:
curl -LsSf https://raw.githubusercontent.com/raicing-ai/functualize/master/install.sh | sh
# Windows
irm https://raw.githubusercontent.com/raicing-ai/functualize/master/install.ps1 | iex
The script picks the archive for your platform — including the musl build on Alpine and
distroless images — and verifies it against the release checksums before installing. Or take
the archive from the releases page
yourself: extract, chmod +x func, run. See
Installation for
the full list of targets.
Verify:
func builtin version
# Or use the longer alias:
functualize builtin version
Both func and functualize are the same command — use whichever you prefer.
Adding to a project (as a library dependency): use
uv add functualizeorpip install functualizeinside your project instead. The core library has no CLI dependencies — addfunctualize[cli]only if your project uses thefuncCLI or TUI.
Managing the installation
func builtin self doctor # how was this installed, and what is wrong with it?
func builtin self update # upgrade in place, restoring what you added
func builtin self install <package> # a dependency your jobs import
func builtin plugin list # what extends this installation
func builtin plugin install <package> # add an extension
Every mutating command prints the exact command it will run before running it. func builtin self update then restores anything you had added — including packages installed through the
func builtin self python -- ... escape hatch, which it never recorded.
Install method decides whether self-update works.
self updatemanages the standalone binary, auv toolinstall, apipxinstall, or a project checkout. A barepip installinto a system interpreter is not self-managing: the command prints guidance, changes nothing, and exits3.func builtin self doctortells you which kind you have.
How func finds jobs
The CLI operates in two modes:
| Command | Mode | Description |
|---|---|---|
func file.py [function] |
Single-file | Run a specific file directly |
func <job_name> |
CWD discovery | Find and run a job by name from the current directory |
func (no args) |
Discovery | List all discovered jobs |
What makes a .py file invocable?
A Python file qualifies as a job module when both conditions are met:
- Filename is not underscore-prefixed — files like
_helpers.pyor__main__.pyare skipped - Contains at least one public top-level function — checked via AST parsing (fast, no import needed)
Once a qualifying file is imported, every public function (non-underscore-prefixed, defined in that module) becomes a registered job command. Imported functions, classes, and private _helper() functions are ignored.
In single-file mode (func file.py [function]), the file is imported directly — the same public-function rule applies. If you omit the function name, func lists all available functions in the file.
Executable scripts (PEP 723)
A script can declare its own entry point and its own dependencies inline, then be run like any other program:
#!/usr/bin/env -S func
# /// script
# dependencies = ["httpx"]
#
# [tool.functualize]
# job = "fetch"
# ///
import httpx
def fetch(url: str, timeout: float = 5.0) -> None:
print(httpx.get(url, timeout=timeout).text)
chmod +x fetch.py
./fetch.py https://example.com --timeout 2
url has no default, so it is a positional argument; timeout has one, so it is --timeout. Same rule as every other job — ./fetch.py --help shows the resulting usage line.
Two things are doing work here:
[tool.functualize] jobnames the function the file runs. Without it,funcreads the first argument as a function name — fine when you are exploring a file (func fetch.py fetch), wrong for a script, where./fetch.py https://example.comwould look for a function calledhttps://example.com. Declaring the job means the file is that job, and everything on the command line belongs to it.dependenciesis standard PEP 723. If any are missing from the current environment,funcre-runs the script throughuv runwith them installed. No virtualenv to create, norequirements.txtto keep in sync.
env -S is what splits func from the filename; a plain #!/usr/bin/env func also works, since there is nothing to split.
CWD discovery
In CWD discovery mode, func locates job directories in this order:
- Explicit config —
pyproject.toml[tool.functualize].jobs_directories, a.functualize.toml, or the global config (~/.config/functualize/config.toml) - Convention directories — a
.functualize/jobs(pluslibandplugins) directory, when present - CWD scan — by default only the current directory itself is scanned for qualifying
.pyfiles. Opt in to a deeper scan withfunc --discovery-depth N(0–5 levels) or persist it in config:
# pyproject.toml (optional — scan subdirectories for jobs)
[tool.functualize.discovery]
scan_depth = 2
Skipped directories: .venv, __pycache__, .git, node_modules, dist, build, and any dot-prefixed directory.
Skipped files: test_*.py, *_test.py, conftest.py, setup.py, and __init__.py.
# pyproject.toml (optional — explicit job directories)
[tool.functualize]
jobs_directories = ["src/myapp/jobs", "scripts"]
Quick Start
Functualize scales from a single script to a full framework project. Start simple, graduate when you need more.
Step 1: Run a Python script
Write a function, run it with func. No project setup, no config files.
# weather.py
from functualize.job import RunContext
def forecast(rc: RunContext):
"""Check today's weather forecast."""
rc.log("Fetching forecast...")
rc.log("Tomorrow: 24°C, sunny")
# Run a function directly
func weather.py forecast
Runnable code:
examples/quickstart/step1_basic/
Step 2: Add typed configuration
Same domain, but now with validated parameters. Pydantic models become CLI options automatically:
# weather.py
from pydantic import BaseModel, Field
from functualize.job import RunContext
class ForecastConfig(BaseModel):
city: str = Field(description="City to check")
days: int = Field(default=3, ge=1, le=7, description="Days to forecast")
api_url: str = Field(default="https://weather.example.com", description="Weather API endpoint")
def forecast(config: ForecastConfig, rc: RunContext) -> str:
rc.log(f"Fetching {config.days}-day forecast for {config.city}...")
rc.log(f"Using API: {config.api_url}")
result = f"{config.city}: 24°C, sunny for the next {config.days} days"
rc.log(result)
return result
Config fields resolve from multiple sources (highest priority first):
# 1. CLI flags
func weather.py forecast --city Tokyo --days 5 --api-url https://api.prod.example.com
# 2. Environment variables (JOBNAME_FIELD convention)
export FORECAST_API_URL=https://api.staging.example.com
func weather.py forecast --city Tokyo
# 3. Config file (if a config.base.toml exists in the directory)
# [forecast]
# api_url = https://weather.example.com
# days = 3
Auto-discovery: When you have multiple job files, put them in a jobs/ directory and point func at it once in pyproject.toml:
myproject/
├── pyproject.toml
└── jobs/
├── weather.py
└── deploy.py
# pyproject.toml
[tool.functualize]
jobs_directories = ["jobs"]
cd myproject
func # Lists all discovered jobs
func forecast # Runs the forecast job directly (no filename needed)
No
pyproject.toml? A one-offfunc --discovery-depth 1scans one directory level below the CWD instead.
Runnable code:
examples/quickstart/step2_config/
Step 3: Invoke jobs with phase tracking
Jobs can invoke other jobs with rc.invoke(). Track progress with rc.track_phase():
# weather.py
from pydantic import BaseModel, Field
from functualize.job import RunContext
from functualize.types import RunStatus
class ForecastConfig(BaseModel):
city: str = Field(description="City to check")
days: int = Field(default=3, ge=1, le=7, description="Days to forecast")
api_url: str = Field(default="https://weather.example.com", description="Weather API endpoint")
def forecast(config: ForecastConfig, rc: RunContext) -> str:
rc.log(f"Fetching {config.days}-day forecast for {config.city}...")
return f"{config.city}: 24°C, sunny for the next {config.days} days"
def alert(config: ForecastConfig, rc: RunContext):
"""Check forecast and send alerts if needed."""
rc.log("Checking alert conditions...")
rc.log("No severe weather — all clear")
def morning_report(config: ForecastConfig, rc: RunContext):
"""Run the full morning weather pipeline."""
rc.track_phase("forecast", "Fetching forecast", RunStatus.RUNNING)
rc.invoke("forecast", city=config.city, days=config.days)
rc.track_phase("forecast", "Forecast retrieved", RunStatus.SUCCESS)
rc.track_phase("alerts", "Checking alerts", RunStatus.RUNNING)
rc.invoke("alert", city=config.city)
rc.track_phase("alerts", "Alerts checked", RunStatus.SUCCESS)
rc.log("Morning report complete")
func weather.py morning_report --city Tokyo --days 5
Install the flow-viz plugin to see a live execution tree — zero code changes to your jobs:
pip install "functualize[cli]" functualize-flow-viz
⏳ morning_report
├─ ✓ forecast — Forecast retrieved (0.1s)
├─ ✓ alerts — Alerts checked (0.1s)
└─ ✓ morning_report (0.3s)
The plugin subscribes to invoke_start, invoke_end, and phase_change events automatically — rendering phase status, durations, and nested invocations without touching job code.
Runnable code:
examples/quickstart/step3_invoke/
Step 4: Browse and run jobs interactively
As your jobs/ directory grows, stop memorizing names and flags. Run bare func in a terminal and the inline TUI opens — a smart command shell rendered under your prompt (not fullscreen):
cd myproject
func
- SmartBar readiness — the command bar's border tells you the state at a glance: grey (no job) → yellow PENDING (required args missing) → green READY (executable) → red INVALID
- Tab — autocomplete job names, flags, and values (enum choices complete after a trailing space)
- Ctrl+Enter — execute in place; log output streams below the bar, and the shell scrollback stays intact
- Ctrl+R — the config panel ring: every config field with its effective value and where it came from (CLI flag, env var, config file, or default)
- Ctrl+E — the general ring: browse all discovered jobs and TUI settings
Requires the CLI extra (pip install "functualize[cli]"). Inline rendering works on Linux/macOS; on Windows, Textual falls back to a fullscreen driver. See the Inline TUI reference for the complete keybinding tables.
Runnable code:
examples/quickstart/step4_tui/
Step 5: Add AI with structured output
If you haven't already, install the CLI extras first:
pip install "functualize[cli]"
Then install the AI domain SDK:
pip install functualize-ai-pydantic
functualize-ai-pydanticpulls infunctualize-ai(the protocol) automatically. When only one AI provider is installed, it's auto-selected — no config needed.
Set your API key (the PydanticAI provider uses LiteLLM, which supports OpenAI, Anthropic, and others):
export OPENAI_API_KEY=sk-...
# Or: export ANTHROPIC_API_KEY=sk-ant-...
Now add an AI-powered job that uses rc.invoke() to call other jobs properly:
# weather.py (add to the same file)
from functualize_ai import AI
class TravelPlan(BaseModel):
destination: str
best_days: list[str]
packing_tips: list[str]
def travel_plan(config: ForecastConfig, ai: AI, rc: RunContext):
"""AI generates a structured travel plan from weather data."""
# Use invoke to get forecast (goes through lifecycle, hooks, plugins)
result = rc.invoke("forecast", city=config.city, days=config.days)
plan = ai.complete(
f"Create a travel plan for {config.city} based on: {result.return_value}",
response_model=TravelPlan,
)
rc.log(f"Best days: {', '.join(plan.best_days)}")
rc.log(f"Pack: {', '.join(plan.packing_tips)}")
func weather.py travel_plan --city Tokyo --days 5
Runnable code (works without API keys via
MockAI):examples/quickstart/step5_ai/
Step 6: Expose jobs to AI agents via MCP
Make your jobs callable by external AI agents (Claude, Cursor, Goose) using the MCP protocol:
pip install "functualize[cli]" functualize-mcp
Mark jobs for external visibility with @job:
from functualize.job.decorators import job
@job(
extra_description="Get a weather forecast for a city",
visibility="external",
tags=["weather", "safe"],
)
def forecast(config: ForecastConfig, rc: RunContext) -> str:
...
@job(
extra_description="Generate an AI travel plan based on weather data",
visibility="external",
tags=["weather", "ai"],
)
def travel_plan(config: ForecastConfig, ai: AI, rc: RunContext):
...
Serve your jobs as MCP tools:
func mcp serve
Using with Claude Code: Add the MCP server to your Claude config:
{
"mcpServers": {
"weather": {
"command": "func",
"args": ["mcp", "serve"],
"cwd": "/path/to/your/project"
}
}
}
Now Claude can discover and call forecast and travel_plan directly, passing structured config and receiving typed results. Jobs with visibility="internal" are hidden from MCP.
Runnable code:
examples/quickstart/step6_mcp/— full tool-surface reference inplugins/functualize-mcp/examples/
Step 7: Workflow checkpoints for AI agents
Use declarative workflows with gates to create bounded, multi-turn flows that AI agents can drive:
# weather.py
from functualize.workflow import workflow, Step, Gate, Edge, END
from pydantic import BaseModel, Field
class TripPreferences(BaseModel):
budget: str = Field(description="Budget level: budget, mid-range, luxury")
interests: list[str] = Field(description="Travel interests")
@workflow(
steps=[
Step(forecast),
Gate(name="preferences", awaits=TripPreferences,
tools=["run_job"], strategy="ai_outbound"),
Step(travel_plan),
],
edges=[
Edge(source="forecast", target="preferences"),
Edge(source="preferences", target="travel_plan"),
Edge(source="travel_plan", target=END),
],
)
def trip_planner(config: ForecastConfig, rc: RunContext) -> str:
"""Multi-step trip planning that pauses for AI input."""
rc.log(f"Itinerary for {config.city} complete.")
return f"Itinerary ready for {config.city}"
The graph is validated at decoration time. Jobs are registered as Step(func); pause points are Gate(name=..., awaits=Model). The decorated function's body is the epilogue — it runs once after the walk reaches END.
Three gate interaction modes:
| Mode | Flag / Strategy | Behavior |
|---|---|---|
| Blocked + Resume | (default) | Walk stops at gate (exit 5). Deposit input via func builtin workflow resume, resume via func --scope-id <id> trip-planner. Best for scripts, CI, and MCP agents. |
| Interactive Prompt | --prompt-gates or strategy="prompt" |
Gate prompts inline on a TTY. Walk completes in one invocation. Falls through to block when piped. |
| AI Agent | strategy="ai_outbound" |
Gate blocks for external AI deliberation via MCP. Agent discovers, inspects state, and deposits input via resume_gate tool. |
# Blocked (default) — two-step: deposit input, then resume
func trip-planner --city Tokyo
# → exit 5: "Blocked: gate 'preferences' in scope 'abc123'"
func builtin workflow resume abc123 preferences --input '{"budget":"mid-range"}'
func --scope-id abc123 trip-planner --city Tokyo
# Interactive — one invocation, gates prompt inline
func --prompt-gates trip-planner --city Tokyo
# → forecast runs → "Budget level?" → "Interests?" → travel-plan runs → exit 0
# AI agent — serve via MCP, agent drives the gate
func mcp serve
# → Claude discovers, inspects state, calls resume_gate, resumes scope
When served via MCP (func mcp serve), functualize-mcp exposes workflow tools that let an AI agent drive paused workflows:
- Discover —
list_active_workflows()shows paused or running workflows - Inspect state —
get_workflow_state(id)shows the current step, pending input model, and available tools - Resume —
resume_gate(id, {"budget": "mid-range", "interests": ["food", "culture"]})validates the input againstTripPreferencesand advances the workflow - Continue multi-turn — each gate creates a natural checkpoint where the agent reflects and decides
This creates bounded AI workflows — the agent operates within defined steps rather than open-ended execution.
Runnable code:
examples/quickstart/step7_workflow/— full workflow walkthrough inplugins/functualize-mcp/examples/
Step 8: Scaffold and distribute as a CLI
When your jobs grow into a real project, scaffold and install it as a standalone command:
func builtin scaffold init weather-app
cd weather-app
uv sync
This generates:
weather-app/
├── pyproject.toml # [project.scripts] entry point
├── README.md
├── config.base.toml
├── config.dev.toml
├── config.prod.toml
└── src/weather_app/
├── __init__.py
├── main.py # FunctualizeApp wiring
└── jobs/
├── __init__.py
└── sample_job.py
Move your weather jobs into src/weather_app/jobs/weather.py. The pyproject.toml declares a CLI entry point:
[project.scripts]
weather-app = "weather_app.main:run"
Install it as a global command (no uv run prefix needed):
# Install globally with uv tool (isolated, on PATH)
uv tool install -e .
# Now callable directly
weather-app forecast --city Tokyo --days 5
weather-app travel-plan --city Paris
Or with pip:
pip install -e .
weather-app --help
MCP works in project mode too — add functualize-mcp as a dependency in your pyproject.toml, and the plugin is auto-discovered at boot via entry points:
weather-app mcp serve
The MCP plugin registers its commands (mcp serve, mcp start, mcp stop, mcp tools, mcp list, mcp schema) automatically when installed. Your project's CLI exposes them alongside your job commands.
Walkthrough:
examples/quickstart/step8_scaffold/— the finished project lives inexamples/project/weather_app/
For the full progression guide (directory mode, library mode, adapter mode), see the Modes documentation.
Layered Configuration
Every JobConfig field resolves from multiple sources automatically. Same job, different environments — zero code changes:
# jobs/sync.py
from pydantic import BaseModel, Field
from functualize.job import RunContext
class SyncConfig(BaseModel):
api_url: str = Field(description="Target API endpoint")
batch_size: int = Field(default=100, description="Records per batch")
timeout: int = Field(default=30, description="Request timeout in seconds")
def data_sync(config: SyncConfig, rc: RunContext):
rc.log(f"Syncing from {config.api_url} (batch={config.batch_size})")
Three ways to provide config — they layer with clear priority. The job is
data-sync: names are canonical lowercase-hyphenated, derived from the Python
function name (def data_sync). Environment variables use underscores because
shells cannot export a hyphen, and a config section is accepted either way:
# 1. CLI flags (highest priority)
func data-sync --batch-size 2000
# 2. Environment variables (JOBNAME_FIELD convention)
export DATA_SYNC_BATCH_SIZE=500
export DATA_SYNC_API_URL=https://api.prod.example.com
# 3. Config files (base + environment overlay)
# config.base.toml
# [data_sync]
# api_url = "https://api.example.com"
# batch_size = 100
Resolution order: Runtime override → CLI → Env vars → Config file → Model defaults (an override is a value rc.config.set() deposits mid-run). The same job works locally, in Docker, and in production without any code changes — just swap the config source.
Config files use a base + environment overlay pattern. The active environment — FUNCTUALIZE_ENV, else ENVIRONMENT, else ENV, defaulting to dev — determines which overlay is merged on top of the base (matched case-insensitively):
# config.base.toml — always loaded
[data_sync]
api_url = "https://api.example.com"
batch_size = 100
# config.prod.toml — merged on top when ENVIRONMENT=prod
[data_sync]
api_url = "https://api.prod.example.com"
batch_size = 500
# Local dev (default) — uses config.base.toml + config.dev.toml
func data-sync
# Production — uses config.base.toml + config.prod.toml overlay
ENVIRONMENT=prod func data-sync
| Preset | Strategy | Best for |
|---|---|---|
classic() |
CLI → Env → Config files → Defaults | Local dev, desktop tools |
twelve_factor() |
CLI → Env → Defaults | Docker, Kubernetes |
env_only(dotenv=True) |
CLI → Env → Defaults | Serverless, minimal setups |
remote_first() |
CLI → Env → Files → Defaults — remote resolution is not wired; see below | — |
remote_first()does not resolve anything remotely. The preset exists and is exported, but nothing in the shipped package constructs aRemoteSource, andremote_first()returnsconfig_resolution_chain=None— which boot turns into the classic chain[CliSource, EnvSource, FileSource, DefaultSource]. It isclassic()with a different file pattern anddotenv=False. Pick it for Vault or AWS Secrets Manager and your credentials come from a local file or the environment instead, with nothing to say so.
Presets are selected in your project's main.py when constructing FunctualizeApp:
from functualize.app import FunctualizeApp, JobSources, twelve_factor
app = FunctualizeApp(
name="weather-app",
job_sources=JobSources(directories=["weather_app.jobs"]),
config_sources=twelve_factor(), # Env-only for Docker/K8s
)
Note: When using
funcCLI in single-file mode, the default preset (classic()) is always used — presets only apply to scaffolded projects with amain.py.
Environment Variables and .env Files
Functualize reads environment variables from os.environ during config resolution. A .env file can inject values into os.environ before resolution runs — controlled by ConfigSources.dotenv / dotenv_path for apps, and by the resolved CLI config plus --dotenv-file / --no-dotenv for the func CLI:
# Explicit .env loading — injects into os.environ before config resolution
myapp --dotenv-file .env data_sync
# App boot honors ConfigSources.dotenv (the dataclass default is True):
# a ./.env in the working directory is loaded at boot. Use
# ConfigSources(dotenv=False) or the twelve_factor() preset to disable.
myapp data_sync
Key points:
- The
ENVIRONMENTvariable (from shell or.env) controls which config overlay file is selected. If your.envsetsENVIRONMENT=prod, the app loadsconfig.prod.tomlon top ofconfig.base.toml - Shell environment variables always take precedence over
.envfile values (python-dotenv does not override existing vars by default) - The effective resolution priority: CLI flags > Shell env vars >
.envfile values > Config files > Model defaults - Only the current working directory's
.env(or an explicitdotenv_path) is considered — there is no upward directory scan, so a.envin a parent directory is never silently picked up - The
funcCLI defaults todotenv = false; opt in per project via[tool.functualize] dotenv = true,FUNCTUALIZE_DOTENV=true, or--dotenv-file
Because .env is loaded into os.environ before the config system reads it, .env can influence both the config values (via JOBNAME_FIELD env vars) and which config files are loaded (via the ENVIRONMENT variable).
Reproducibility tip: Automatic
.envloading can cause hard-to-debug differences between environments. For CI and production, usetwelve_factor()/ConfigSources(dotenv=False)(or--no-dotenvon the CLI) so environment variables come only from the orchestrator.
Extending with Plugins
Plugins are standalone packages that extend any Functualize app via Python entry points. Install one and it's active immediately — no code changes in the host app.
Writing a plugin
A plugin is a class with metadata attributes and a __call__(app) method:
# src/functualize_metrics/__init__.py
class MetricsPlugin:
name = "metrics"
version = "1.0.0"
description = "Emit job execution metrics to StatsD"
def __call__(self, app) -> None:
"""Called at boot — hook into lifecycle events."""
@app.before_job
def on_start(job_name, config):
statsd.increment(f"job.{job_name}.started")
@app.on_job_success
def on_success(job_name, result, duration):
statsd.timing(f"job.{job_name}.duration", duration)
@app.on_job_failure
def on_failure(job_name, error):
statsd.increment(f"job.{job_name}.failed")
Register via entry point in pyproject.toml:
[project.entry-points."functualize.plugins"]
metrics = "functualize_metrics:MetricsPlugin"
Once installed (pip install functualize-metrics), the plugin is auto-discovered at boot. Every job in every Functualize app gets metrics automatically.
Registering CLI commands from a plugin
Plugins can add sub-commands to the host CLI:
class DBPlugin:
name = "db-tools"
version = "1.0.0"
description = "Database management commands"
def __call__(self, app) -> None:
def migrate(target: str = "head"):
"""Run database migrations."""
print(f"Migrating to {target}")
def seed(count: int = 100):
"""Seed sample data."""
print(f"Seeding {count} records")
app.register_plugin_command("migrate", migrate, group="db", help_text="Run migrations")
app.register_plugin_command("seed", seed, group="db", help_text="Seed data")
This creates my-app db migrate and my-app db seed commands.
Registering dynamic jobs
Plugins can register jobs that become invocable via rc.invoke() and visible in the CLI:
class HealthPlugin:
name = "health-monitor"
version = "1.0.0"
description = "Registers a health check job"
def __call__(self, app) -> None:
def check_health(config, rc):
"""Check endpoint health."""
import httpx
resp = httpx.get(config.endpoint, timeout=config.timeout)
rc.log(f"Status: {resp.status_code}")
app.register_dynamic_job(
name="health-check",
function=check_health,
config_class=HealthCheckConfig,
group="monitoring",
)
Dynamic jobs are fully functional — invocable via rc.invoke("health-check"), visible in the TUI, and trigger lifecycle hooks.
Public API
| Package | Purpose |
|---|---|
functualize.app |
FunctualizeApp constructor, config presets, adapters |
functualize.job |
RunContext, capabilities (Log, Invoke, Prompt, Perf, State), @job decorator |
functualize.plugin |
EventBus, JobProvider, AdapterPlugin |
functualize.types |
JobResult, JobDescriptor, enums |
functualize.workflow |
@workflow, Step, Gate, Edge, ConditionalEdge, END |
functualize.testing |
TestRunContext, CapturingLog, MockInvoke |
See the full plugin and extension docs for lifecycle hooks, middleware, event bus, custom providers, and more.
Plugin Ecosystem
Install the full plugin ecosystem with a single command:
pip install "functualize[all]"
| Plugin | Purpose |
|---|---|
functualize-ai |
Provider-agnostic LLM interaction with budget enforcement and tool scoping |
functualize-ai-pydantic |
PydanticAI-backed AI provider with LiteLLM routing and structured output |
functualize-flow-viz |
Live inline execution tree visualization with step status and durations |
functualize-http |
HTTP delivery adapter exposing jobs as API endpoints via stdlib asyncio |
functualize-inline |
Textual-based inline terminal widgets for prompts, selections, and progress |
functualize-lambda |
AWS Lambda delivery adapter for serverless job execution |
functualize-mcp |
Model Context Protocol adapter exposing jobs as tools to AI agents |
functualize-state |
State domain SDK with protocols for key-value persistence and execution tracking |
functualize-state-sqlite |
SQLite-backed state persistence and execution history in WAL mode |
functualize-tasks |
Task management domain SDK with status tracking and event emission |
functualize-tasks-local |
Local state-backed task storage provider for the tasks domain |
Every plugin ships runnable examples in its own folder: plugins/<name>/examples/.
For plugin quality tiers and publishing guidelines, see plugins/PUBLISHING.md.
Discovering the Command Surface
Every functualize app describes itself at runtime. One call returns every command — your jobs and the builtins — with the arguments each accepts, as JSON Schema:
func builtin info schema
[{"name": "demo.report", "kind": "job", "path": ["demo", "report"],
"description": "Emit a small report.",
"inputSchema": {"type": "object",
"properties": {"rows": {"type": "integer", "default": 3}}}}]
No plugin required, and no walking each group's --help in turn. Narrow with
--kind job or --kind builtin, or name one command by its dotted path
(func builtin info schema builtin.skills.materialize). It is the same
renderer that builds the MCP tool definitions, so what you read is exactly what
a tool call would accept.
| Command | Answers |
|---|---|
func builtin info |
The overview: jobs, config resolution, state path, skills |
func builtin info jobs [<job>] |
The catalogue, or one job in detail (--json for structure) |
func builtin info schema [<name>] |
Input contracts as JSON Schema, jobs and builtins — always JSON |
func builtin info all |
Everything above as one document |
func builtin why <job> |
Whether a job would run, and why |
Set the renderer once instead of passing a flag every time:
export FUNCTUALIZE_CLI_OUTPUT=json # or "plain" for no box-drawing
--help names all of this at the bottom, so nothing above needs to be
memorised — on func and on your project's own entry point alike, spelled for
whichever one you invoked:
For AI agents:
func builtin info schema all commands, as JSON
func builtin info schema --kind job jobs only
func builtin info schema --kind builtin builtin commands only
func builtin skills list skills for this version
export FUNCTUALIZE_CLI_OUTPUT=json make JSON the default
AI Agent Skills
Functualize ships Agent Skills that teach a coding
agent the contracts which are invisible from the file it is editing — that
capabilities are injected by parameter type, that returning a value does not
print it, that discovery is convention plus filters, and that func is
frequently not on PATH.
They travel inside the distribution, so what your agent reads is the version you actually installed rather than whatever the main branch says today.
func builtin skills list # what ships, with descriptions
func builtin skills install # install into this project (uses npx skills)
| Skill | For |
|---|---|
functualize |
Writing, running and debugging jobs in an existing project |
functualize-app |
Building a CLI or TUI on functualize, end to end |
functualize-cli |
Installing, upgrading and configuring func itself |
functualize-skill |
Authoring an agent skill whose scripts are functualize jobs |
Without Node, copy them yourself:
cp -R "$(func builtin skills path)"/* .claude/skills/
func builtin skills materialize writes a version-stamped copy under
$XDG_DATA_HOME/functualize/skills/ for when the environment holding the wheel
is disposable (uvx, PEP 723 script envs) or a project that does not depend on
functualize still needs a stable path.
Requirements
- Python 3.11+
Development
# Clone and install
git clone https://github.com/raicing-ai/functualize.git
cd functualize
# Install tooling (mise manages python + uv versions)
mise install
# Sync dependencies (creates .venv, installs all workspace packages)
uv sync
# Run fast tests (unit only, skips property-based tests)
uv run pytest
# Run full test suite including property-based tests
uv run pytest --run-slow
# Run full suite exactly as CI does (the ci profile draws 200 examples, not 100)
HYPOTHESIS_PROFILE=ci uv run pytest --run-slow --cov=functualize -n auto
# Lint and format
uv run ruff check src/ tests/
uv run ruff format src/ tests/
# Type check
uv run mypy src/
# Architecture enforcement (import-linter)
uv run lint-imports
# Run all pre-commit hooks
uv run pre-commit run --all-files
See CONTRIBUTING.md for the full development guide.
Documentation
# Install docs dependencies
uv sync --group docs
# Live preview
uv run mkdocs serve
# Build (strict mode catches broken links)
uv run mkdocs build --strict
The full documentation is published at https://raicing-ai.github.io/functualize/.
Docs deploy automatically to GitHub Pages on push to master.
Contributing
Contributions are welcome! Please read our Contributing Guide before submitting a PR.
- Fork the repository
- Create a feature branch (
git checkout -b feat/my-feature) - Make your changes with tests
- Ensure CI checks pass (lint, type check, tests, import-linter)
- Open a Pull Request against
master(the PR title becomes the squash commit — use a Conventional Commit subject)
Changelog
See CHANGELOG.md for release history and migration notes.
Security
See SECURITY.md for reporting vulnerabilities.
License
MIT © Mohammad Hakim Adiprasetya
Release files for functualize 0.2.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| functualize-0.2.1.tar.gz | 908.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| functualize-0.2.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 2.0 MB
Release files / functualize-0.2.1.tar.gz
| Download URL | functualize-0.2.1.tar.gz |
|---|---|
| Size | 908.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
aafbe023ce05fb5441806a59b96954dfdb91a7565dba196b79421e7f2abcc375
|
|
BLAKE2b-256 checksum How to use checksums |
570ea485a1f8856bde876fde0ed4cacd35d8a8ab9e97632a56dd585599890ef1
|
| 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 4, 2026.
Transparency logRelease files / functualize-0.2.1-py3-none-any.whl
| Download URL | functualize-0.2.1-py3-none-any.whl |
|---|---|
| Size | 1.1 MB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
14324a6d67402c869ba2fbee5ae4d6cbeb361210f77ebe515c900c234d0cc901
|
|
BLAKE2b-256 checksum How to use checksums |
d0d948bee38ec6e03dca3a8b01de7f7f429cb0b36750f2f5b81f2bb5ab383a99
|
| 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 4, 2026.
Transparency log