Overview
FrameX is a plugin-first Python framework for teams that need service decomposition, multi-team parallel development, private implementation boundaries, and one consistent service surface across local plugins and upstream APIs.
It is designed for services that are growing beyond a single cohesive code path and need clearer capability boundaries without forcing teams to build a custom platform first.
What Problem It Solves
FrameX is most useful when multiple teams need to ship capabilities in parallel, call each other through stable service interfaces, and keep implementation details private so each team can work without understanding or depending on other teams' codebases.
Use it when you need to:
- build service capabilities as plug-and-play modules
- let multiple engineers or teams ship in parallel with clearer ownership boundaries
- split a growing service into independently evolving capability units
- call other teams' capabilities without depending on their codebases
- expose local plugins and upstream APIs behind one consistent service surface
- integrate third-party or internal HTTP services with minimal client-side changes
- start with simple local execution and scale to Ray when needed
- keep the system extensible as capabilities, teams, and traffic grow
Core Concepts
FrameX is built around a few core ideas:
Plugin: a capability package with its own code, metadata, and API surface@on_register(): registers a plugin class as a runtime unit@on_request(...): exposes plugin methods as HTTP APIs, internal callable APIs, or bothrequired_remote_apis: declares which other plugin or HTTP APIs a plugin depends oncall_plugin_api(...): lets one capability call another through a stable service interface@remote(): keeps the same call style across local execution and Ray executionproxyplugin: makes upstream OpenAPI services look like part of the same service surface
Why FrameX Instead Of Plain FastAPI
Plain FastAPI is a good choice for a single cohesive application. FrameX is better when the real problem is not route handling, but service decomposition, team boundaries, and cross-service integration.
Compared with plain FastAPI, FrameX gives you:
- plugin boundaries for clearer ownership between capabilities and teams
- a better development model for plug-and-play modules and parallel delivery
- one consistent surface for local capabilities and upstream HTTP services
- internal callable APIs in addition to normal HTTP routes
- explicit dependency declarations between capabilities
- the ability to start locally and move to Ray-backed execution without rewriting plugin code
If you only need a small application with a stable route surface and one codebase, plain FastAPI is usually simpler.
Where It Fits Best
FrameX is a good fit when you want to:
- build modular service capabilities as plugins
- support multi-person or multi-team parallel development
- reduce cross-team code familiarity requirements
- expose local modules and upstream APIs behind one consistent service surface
- start with a simple deployment model and scale execution later
Typical scenarios include:
- multi-team service development with stable interfaces and private implementation boundaries
- capability-oriented service splitting inside one growing service
- transparent upstream HTTP integration through the same service boundary
- gradual scaling from local execution to Ray-backed execution
Quick Start
This chapter shows the shortest path to running FrameX locally.
By the end, you will:
- install FrameX
- create one minimal plugin
- start the service
- call the plugin over HTTP
- understand what to read next
Requirements
- Python
>=3.11
Install FrameX
Install the base package:
pip install framex-kit
If you plan to use Ray later, install the optional extra:
pip install "framex-kit[ray]"
Create a Minimal Plugin
Create a file named foo.py:
from typing import Any
from pydantic import BaseModel
from framex.consts import VERSION
from framex.plugin import BasePlugin, PluginMetadata, on_register, on_request
__plugin_meta__ = PluginMetadata(
name="foo",
version=VERSION,
description="A minimal example plugin",
author="you",
url="https://github.com/touale/FrameX-kit",
)
class EchoBody(BaseModel):
text: str
@on_register()
class FooPlugin(BasePlugin):
def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
@on_request("/foo", methods=["GET"])
async def echo(self, message: str) -> str:
return f"foo: {message}"
@on_request("/foo_model", methods=["POST"])
async def echo_model(self, model: EchoBody) -> dict[str, str]:
return {"text": model.text}
This example exposes two plugin APIs:
GET /api/v1/fooPOST /api/v1/foo_model
FrameX discovers the plugin module, registers the plugin class, and mounts its APIs into one FastAPI service surface.
Run the Service
Start FrameX from the same directory:
PYTHONPATH=. framex run --load-plugins foo
Important:
--load-pluginsis a repeatable option- it is not a comma-separated list
PYTHONPATH=.lets Python import the localfoo.pymodule
You can also start with the built-in example plugin:
framex run --load-builtin-plugins echo
Call the API
Call the GET endpoint:
curl "http://127.0.0.1:8080/api/v1/foo?message=hello"
Expected response:
"foo: hello"
Call the POST endpoint:
curl -X POST "http://127.0.0.1:8080/api/v1/foo_model" \
-H "Content-Type: application/json" \
-d '{"text":"hello from post"}'
Expected response:
{"text":"hello from post"}
Open the API Docs
FrameX exposes standard FastAPI docs:
http://127.0.0.1:8080/docshttp://127.0.0.1:8080/redochttp://127.0.0.1:8080/api/v1/openapi.json
What Just Happened
In this quick start, FrameX handled four things for you:
- plugin discovery from the module you loaded
- plugin registration through
@on_register() - HTTP API exposure through
@on_request(...) - unified service routing through one FastAPI ingress
That is the core development model: package a capability as a plugin, expose stable APIs, and let FrameX assemble them into one service.
Next Steps
Read the next chapters in this order:
Basic Usage / OverviewProject StructurePlugin Register & API ExposeCross-Plugin AccessPlugin Loading & Startup
If you are evaluating FrameX for a larger system, focus on:
- plugin boundaries
call_plugin_api(...)- proxy-based upstream integration
- local vs Ray execution
Basic Usage Overview
This section covers the day-to-day programming model of FrameX.
The goal is to help you build service capabilities as plugins, expose them through a consistent API surface, and organize a codebase that can scale across people, teams, and execution modes.
What You Will Learn
After this section, you will understand how to:
- organize a FrameX project and place plugin modules clearly
- define plugin metadata and register runtime units with
@on_register() - expose HTTP APIs and internal callable APIs with
@on_request(...) - declare dependencies with
required_remote_apisand call capabilities withcall_plugin_api(...) - configure plugins and runtime settings cleanly
- load plugins and start the service through CLI or configuration
- debug and test plugins in local, non-Ray mode
Section Roadmap
-
Project Structure Understand how to organize a FrameX project and where plugin modules usually live.
-
Plugin Register & API Expose Learn how plugin metadata,
@on_register(), and@on_request(...)define the service surface. -
Cross-Plugin Access Learn how one capability calls another through
required_remote_apisandcall_plugin_api(...). -
Plugin Configuration Define plugin-specific configuration and load it through FrameX settings.
-
Plugin Loading & Startup Start FrameX with the right plugin set through CLI options and configuration files.
-
Plugin Debugging & Testing Debug plugins locally and test them with normal FastAPI-compatible tooling.
What This Section Is For
Use this section when you are:
- building your first real plugin
- turning a growing service into modular capabilities
- standardizing how teams expose and consume service interfaces
- preparing for later use of proxy mode or Ray without changing the basic development model
Project Structure
In practice, FrameX projects usually fall into two different structures.
You should choose the structure based on what you are publishing and how many plugins the project contains.
Structure 1: Single Plugin Package
Use this structure when the project itself is one plugin package.
This is a good fit when you want to:
- build one reusable plugin
- publish it to PyPI
- let other FrameX projects load it directly by import path
Recommended layout:
your_plugin/
├── pyproject.toml
├── README.md
├── src/
│ └── your_plugin/
│ ├── __init__.py
│ ├── config.py
│ ├── models.py
│ └── service.py
└── tests/
In this structure, the plugin entry is usually defined in:
src/your_plugin/__init__.py
That means other projects can load it directly, for example:
framex run --load-plugins your_plugin
Or in config.toml:
load_plugins = ["your_plugin"]
When to use it
Choose this structure when:
- the package contains one plugin
- the plugin is intended to be reused across projects
- you want the package name itself to be the plugin import path
Why it works
Because the whole package is the plugin, using src/your_plugin/__init__.py as the plugin entry is natural here.
The package is small, self-contained, and meant to be loaded as one unit.
Structure 2: Multi-Plugin Project
Use this structure when one project contains multiple plugins.
This is a good fit when you want to:
- build several capabilities in one service
- let different people or teams own different plugins
- load only part of the project in different environments
Recommended layout:
your_project/
├── pyproject.toml
├── config.toml
├── README.md
├── src/
│ └── your_project/
│ ├── __init__.py
│ ├── __main__.py
│ ├── consts.py
│ └── plugins/
│ ├── __init__.py
│ ├── foo.py
│ └── bar/
│ ├── __init__.py
│ ├── config.py
│ └── service.py
└── tests/
In this structure, plugins usually live under:
src/your_project/plugins/
Typical import paths are:
your_project.plugins.fooyour_project.plugins.bar
Examples:
framex run --load-plugins your_project.plugins.foo
load_plugins = [
"your_project.plugins.foo",
"your_project.plugins.bar",
]
When to use it
Choose this structure when:
- one project contains multiple plugins
- different capabilities should stay clearly separated
- you want cleaner ownership boundaries inside one codebase
Why it works
Because the project package and the plugin modules are different things.
your_projectis the application packageyour_project.plugins.*are the plugin packages or modules
That separation makes the codebase easier to understand and maintain.
Single File vs Package Plugin
Inside a multi-plugin project, each plugin can still be organized in two ways.
Single-file plugin
src/your_project/plugins/
└── foo.py
Use this when the plugin is still small.
Package plugin
src/your_project/plugins/
└── bar/
├── __init__.py
├── config.py
├── models.py
└── service.py
Use this when the plugin is larger and needs multiple modules.
Built-In Plugins vs Your Plugins
Keep built-in plugins and your own plugins separate.
- built-in plugins come from
framex.plugins, such asechoandproxy - your own plugins come from your own package
Example:
framex run \
--load-builtin-plugins echo \
--load-plugins your_project.plugins.foo
Rule of Thumb
Use this simple rule:
- if the package itself is one reusable plugin, put the plugin in
src/your_plugin/__init__.py - if the project contains multiple plugins, create
src/your_project/plugins/ - if one plugin grows large, turn it from one file into one package
Plugin Register & API Expose
This chapter explains the core FrameX programming model:
- define plugin metadata
- register a plugin class
- expose APIs from plugin methods
If you understand these three pieces, you understand how a capability becomes part of a FrameX service.
Define Plugin Metadata
Each plugin module should define __plugin_meta__ with PluginMetadata(...).
Example:
from framex.consts import VERSION
from framex.plugin import PluginMetadata
__plugin_meta__ = PluginMetadata(
name="foo",
version=VERSION,
description="A minimal example plugin",
author="you",
url="https://github.com/touale/FrameX-kit",
required_remote_apis=["/api/v1/echo", "echo.EchoPlugin.confess"],
)
Important fields
name: plugin nameversion: plugin versiondescription: short description of what the plugin doesauthor: maintainer or owning teamurl: repo or documentation URLrequired_remote_apis: APIs this plugin depends on
required_remote_apis can contain:
- HTTP paths such as
/api/v1/echo - function API names such as
echo.EchoPlugin.confess
This metadata is used for plugin discovery, dependency resolution, and runtime registration.
Register the Plugin Class
A plugin class is registered with @on_register() and usually inherits from BasePlugin.
Example:
from typing import Any
from framex.plugin import BasePlugin, on_register
@on_register()
class FooPlugin(BasePlugin):
def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
Lifecycle guidance
- use
__init__for lightweight synchronous setup - use
on_startfor heavier or async initialization when needed
Example:
@on_register()
class FooPlugin(BasePlugin):
def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
async def on_start(self) -> None: ...
Expose APIs with @on_request(...)
Use @on_request(...) on plugin methods to expose them as callable APIs.
Typical modes are:
- HTTP API: provide a route path
- function API: use
call_type=ApiType.FUNC - both: use
call_type=ApiType.ALL
HTTP API example
@on_request("/echo", methods=["GET"])
async def echo(self, message: str) -> str:
return message
A path like "/echo" is exposed through the normal FrameX HTTP surface, typically as /api/v1/echo.
HTTP API with request body
from pydantic import BaseModel
class EchoBody(BaseModel):
text: str
@on_request("/echo_model", methods=["POST"])
async def echo_model(self, model: EchoBody) -> dict[str, str]:
return {"text": model.text}
Function API example
from framex.plugin.model import ApiType
@on_request(call_type=ApiType.FUNC)
async def confess(self, message: str) -> str:
return f"received: {message}"
This creates an internal callable API. A typical function API name looks like:
echo.EchoPlugin.confess
Expose both HTTP and function access
@on_request("/echo", methods=["GET"], call_type=ApiType.ALL)
async def echo(self, message: str) -> str:
return message
Use this when the same capability should be reachable both as an HTTP route and as an internal callable API.
Important Implementation Notes
The current implementation has a few rules worth knowing:
- a handler may declare at most one
BaseModelparameter stream=Truecreates a streaming endpointraw_response=Truebypasses the default response wrapper
Streaming example
from collections.abc import AsyncGenerator
@on_request("/echo_stream", methods=["GET"], stream=True)
async def echo_stream(self, message: str) -> AsyncGenerator[str, None]:
for chunk in [message, "done"]:
yield chunk
Raw response example
@on_request("/healthz", methods=["GET"], raw_response=True)
async def healthz(self) -> dict[str, str]:
return {"status": "ok"}
Without raw_response=True, normal non-streaming HTTP responses are wrapped by FrameX into the standard response envelope.
Minimal End-to-End Example
from typing import Any
from pydantic import BaseModel
from framex.consts import VERSION
from framex.plugin import BasePlugin, PluginMetadata, on_register, on_request
from framex.plugin.model import ApiType
__plugin_meta__ = PluginMetadata(
name="foo",
version=VERSION,
description="A minimal example plugin",
author="you",
url="https://github.com/touale/FrameX-kit",
required_remote_apis=[],
)
class EchoBody(BaseModel):
text: str
@on_register()
class FooPlugin(BasePlugin):
def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
@on_request("/echo", methods=["GET"])
async def echo(self, message: str) -> str:
return message
@on_request("/echo_model", methods=["POST"])
async def echo_model(self, model: EchoBody) -> dict[str, str]:
return {"text": model.text}
@on_request(call_type=ApiType.FUNC)
async def confess(self, message: str) -> str:
return f"received: {message}"
This plugin exposes:
- HTTP
GET /api/v1/echo - HTTP
POST /api/v1/echo_model - function API
foo.FooPlugin.confess
Rule of Thumb
Keep this mental model:
PluginMetadatadescribes the capability@on_register()makes the class a runtime plugin@on_request(...)turns methods into FrameX APIs
That is the core contract for building plugins in FrameX.
Cross-Plugin Access
Cross-plugin access is how one FrameX capability calls another through a stable service interface.
This matters when:
- one plugin composes another capability
- multiple teams expose capabilities to each other
- callers should depend on APIs, not on another team's codebase
- part of the dependency graph may later move behind proxy mode
The model is simple:
- declare what your plugin depends on
- call those APIs through
call_plugin_api(...)
Declare Dependencies with required_remote_apis
A plugin should declare the APIs it depends on in PluginMetadata.required_remote_apis.
Example:
__plugin_meta__ = PluginMetadata(
name="invoker",
version=VERSION,
description="Calls APIs from another plugin",
author="you",
url="https://github.com/touale/FrameX-kit",
required_remote_apis=[
"/api/v1/echo",
"/api/v1/echo_model",
"/api/v1/echo_stream",
"echo.EchoPlugin.confess",
],
)
required_remote_apis can contain:
- HTTP paths such as
/api/v1/echo - function API names such as
echo.EchoPlugin.confess
This declaration gives FrameX a stable contract for dependency resolution.
Call Dependencies with call_plugin_api(...)
Use call_plugin_api(api_name, **kwargs) to call another registered API.
Example:
from framex import call_plugin_api
result = await call_plugin_api("/api/v1/echo", message="hello")
FrameX resolves the target from required_remote_apis. If proxy mode is enabled, unresolved HTTP paths can fall back to the built-in proxy plugin.
Plugin classes also have an internal convenience wrapper around the same mechanism, but call_plugin_api(...) is the public API this guide uses.
Which API Name Should You Use?
Use an HTTP path when the target capability is naturally exposed as a route:
/api/v1/echo/api/v1/echo_model/api/v1/echo_stream
Use a function API name when the target capability is internal-only and should not be exposed as a public HTTP route:
echo.EchoPlugin.confess
A simple rule:
- use HTTP paths for normal service-facing capabilities
- use function APIs for internal capability-to-capability calls
Common Calling Patterns
Call an HTTP API with simple parameters
Provider:
@on_request("/echo", methods=["GET"])
async def echo(self, message: str) -> str:
return message
Caller:
from framex import call_plugin_api
result = await call_plugin_api("/api/v1/echo", message=message)
Call an HTTP API with a model payload
Provider:
class EchoBody(BaseModel):
text: str
@on_request("/echo_model", methods=["POST"])
async def echo_model(self, model: EchoBody) -> dict[str, str]:
return {"text": model.text}
Caller:
from framex import call_plugin_api
result = await call_plugin_api(
"/api/v1/echo_model",
model={"text": "hello"},
)
For model payloads, pass a dict.
Call a streaming API
Provider:
@on_request("/echo_stream", methods=["GET"], stream=True)
async def echo_stream(self, message: str) -> AsyncGenerator[str, None]: ...
Caller:
from framex import call_plugin_api
stream = await call_plugin_api("/api/v1/echo_stream", message=message)
Call a function API
Provider:
@on_request(call_type=ApiType.FUNC)
async def confess(self, message: str) -> str:
return f"received: {message}"
Caller:
from framex import call_plugin_api
result = await call_plugin_api("echo.EchoPlugin.confess", message=message)
How to Discover Available APIs
The easiest places to inspect available APIs are:
- startup logs
/docs/redoc/api/v1/openapi.json
Typical startup logs include entries like:
[SUCCESS] ... Found plugin HTTP API "/api/v1/echo" from plugin(echo)
[SUCCESS] ... Found plugin HTTP API "/api/v1/echo_stream" from plugin(echo)
[SUCCESS] ... Found plugin FUNC API "echo.EchoPlugin.confess" from plugin(echo)
Minimal End-to-End Example
from typing import Any
from framex import call_plugin_api
from framex.consts import VERSION
from framex.plugin import BasePlugin, PluginMetadata, on_register, on_request
__plugin_meta__ = PluginMetadata(
name="invoker",
version=VERSION,
description="Calls APIs from another plugin",
author="you",
url="https://github.com/touale/FrameX-kit",
required_remote_apis=[
"/api/v1/echo",
"echo.EchoPlugin.confess",
"/api/v1/echo_model",
],
)
@on_register()
class InvokerPlugin(BasePlugin):
def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
@on_request("/evoke_echo", methods=["GET"])
async def evoke(self, message: str) -> dict[str, Any]:
echo = await call_plugin_api("/api/v1/echo", message=message)
confess = await call_plugin_api("echo.EchoPlugin.confess", message=message)
echo_model = await call_plugin_api(
"/api/v1/echo_model",
model={"text": message},
)
return {
"echo": echo,
"confess": confess,
"echo_model": echo_model,
}
Rule of Thumb
Keep this mental model:
required_remote_apisdeclares what your plugin depends oncall_plugin_api(...)is the public calling interface- HTTP paths are for service-facing APIs
- function API names are for internal callable APIs
That keeps cross-plugin dependencies explicit, stable, and easier to evolve.
Plugin Configuration
FrameX configuration has two layers:
- runtime configuration for the whole service
- plugin-specific configuration under
plugins.<plugin_name>
This chapter focuses on the settings you are most likely to use in real projects.
Where Configuration Comes From
FrameX loads settings from these sources:
- environment variables
.env.env.prodconfig.toml[tool.framex]inpyproject.toml
In the current implementation, environment variables take highest priority. config.toml and [tool.framex] are useful project-level defaults.
CLI options are then applied before startup, so flags such as --port, --load-plugins, and --load-builtin-plugins can override configuration at runtime.
Minimal config.toml
For most projects, config.toml is the clearest place to start.
Example:
load_builtin_plugins = ["echo"]
load_plugins = ["your_project.plugins.foo"]
[server]
host = "127.0.0.1"
port = 8080
use_ray = false
enable_proxy = false
[plugins.foo]
debug = true
The most common top-level fields are:
load_builtin_pluginsload_pluginsserverpluginsauth
Most Common Runtime Settings
In normal usage, the most important settings are:
server.hostserver.portserver.use_rayserver.enable_proxyload_builtin_pluginsload_pluginsplugins.<plugin_name>auth.rules
You do not need to understand every field in the global Settings model before using FrameX productively. Most projects only touch a small subset.
Plugin-Specific Configuration
Plugin-specific settings live under:
[plugins.<plugin_name>]
Example:
[plugins.foo]
debug = true
timeout = 30
This keeps plugin settings separate from global runtime settings.
Typed Plugin Configuration
If a plugin wants typed configuration, it can declare a Pydantic model and attach it through config_class in PluginMetadata.
Define a config model
from pydantic import BaseModel
class FooConfig(BaseModel):
debug: bool = False
timeout: int = 30
Attach it to plugin metadata
__plugin_meta__ = PluginMetadata(
name="foo",
version=VERSION,
description="A minimal example plugin",
author="you",
url="https://github.com/touale/FrameX-kit",
required_remote_apis=[],
config_class=FooConfig,
)
Read it inside the plugin
from framex.plugin import get_plugin_config
settings = get_plugin_config("foo", FooConfig)
If no config is provided for that plugin, FrameX returns the config model with its default values and logs a warning.
Example: Plugin Config in config.toml
If the plugin is named foo, the matching config block looks like this:
[plugins.foo]
debug = true
timeout = 30
That block is what get_plugin_config("foo", FooConfig) reads.
Environment Variables
Nested settings can also be provided through environment variables with __ as the separator.
Examples:
export SERVER__PORT=9000
export SERVER__ENABLE_PROXY=true
export PLUGINS__FOO__DEBUG=true
Because the current settings model uses case_sensitive=False, you do not need to rely on lowercase-only keys.
When to Use Which Format
Use config.toml when:
- you want readable project defaults
- the configuration is hierarchical
- you want plugin settings grouped clearly in version control
Use environment variables when:
- deployment environments need different overrides
- secrets or environment-specific values should not live in the repo
- CI, containers, or runtime platforms inject settings dynamically
A practical pattern is:
- keep stable defaults in
config.toml - use environment variables for deployment-specific overrides
Rule of Thumb
Keep this mental model:
server.*configures the runtimeload_pluginsandload_builtin_pluginscontrol what gets loadedplugins.<plugin_name>configures one pluginconfig_class+get_plugin_config(...)gives that plugin typed settings
That is enough for most real FrameX projects.
Plugin Loading & Startup
This chapter explains how FrameX loads plugins and starts the runtime.
Loading Methods
FrameX supports two loading methods:
- load plugins in Python code
- declare plugins in configuration
In both cases, startup ends with framex.run().
Load Plugins in Python Code
Use load_builtin_plugins(...) for built-in plugins and load_plugins(...) for your own plugins.
import framex
def main() -> None:
framex.load_builtin_plugins("echo")
framex.load_plugins("your_project.plugins.foo")
framex.run()
if __name__ == "__main__":
main()
Multiple Plugins
import framex
def main() -> None:
framex.load_builtin_plugins("echo", "proxy")
framex.load_plugins(
"your_project.plugins.foo",
"your_project.plugins.bar",
)
framex.run()
Package Path
You can also load a package path:
framex.load_plugins("your_project.plugins")
Use a module path when you want one plugin:
your_project.plugins.foo
Use a package path when you want FrameX to search under that package:
your_project.plugins
Every loaded plugin module or package must define:
__plugin_meta__ = PluginMetadata(...)
Load Plugins from Configuration
You can declare the startup plugin list in config.toml:
load_builtin_plugins = ["echo", "proxy"]
load_plugins = [
"your_project.plugins.foo",
"your_project.plugins.bar",
]
Then start FrameX normally:
import framex
framex.run()
This is useful when different environments need different plugin sets without changing code.
Load Plugins from CLI
The CLI exposes the same loading model.
framex run --load-builtin-plugins echo --load-plugins your_project.plugins.foo
Both options are repeatable:
framex run \
--load-builtin-plugins echo \
--load-builtin-plugins proxy \
--load-plugins your_project.plugins.foo \
--load-plugins your_project.plugins.bar
Do not pass them as comma-separated strings.
Built-In Plugins vs Your Plugins
load_builtin_plugins(...)loads built-in plugins such asechoandproxyload_plugins(...)loads your own plugin import paths
Example:
framex.load_builtin_plugins("proxy")
framex.load_plugins("your_project.plugins.foo")
Startup Order
The normal startup sequence is:
- load built-in plugins if needed
- load your own plugins
- call
framex.run()
If you are using configuration-based loading, framex.run() reads the configured plugin lists and starts the runtime.
Rule of Thumb
Use this simple rule:
- one plugin module:
your_project.plugins.foo - multiple plugin modules: repeat
load_plugins(...) - one plugin package tree:
your_project.plugins - built-in plugin:
load_builtin_plugins(...)
Keep plugin paths explicit and stable. That makes startup behavior easier to understand and debug.
Plugin Debugging & Testing
This chapter covers two practical tasks:
- debugging plugins locally
- testing plugins with
pytest
Debug Plugins Locally
For local debugging, keep Ray disabled.
config.toml:
[server]
use_ray = false
In this mode, FrameX runs as a normal FastAPI application, so you can debug it like any other Python web service.
Keep Ray Off During Normal Debugging
If you are still changing plugin logic, keep use_ray = false until the behavior is stable.
Use Ray only when you specifically need to debug distributed execution behavior.
Build a Test App
For tests, start FrameX in test mode and return the FastAPI app.
import pytest
from fastapi import FastAPI
import framex
@pytest.fixture(scope="session")
def test_app() -> FastAPI:
framex.load_builtin_plugins("echo")
framex.load_plugins("your_project.plugins.foo")
return framex.run(test_mode=True) # type: ignore[return-value]
Use TestClient
Wrap the test app with TestClient:
from typing import Generator
import pytest
from fastapi.testclient import TestClient
@pytest.fixture(scope="session")
def client(test_app) -> Generator[TestClient, None, None]:
with TestClient(test_app) as test_client:
yield test_client
Example HTTP Test
from fastapi.testclient import TestClient
def test_echo(client: TestClient) -> None:
response = client.get("/api/v1/echo", params={"message": "hello"})
assert response.status_code == 200
assert response.json()["data"] == "hello"
Test Streaming APIs
If a plugin exposes a streaming endpoint, test it with client.stream(...).
def test_stream(client: TestClient) -> None:
with client.stream(
"GET", "/api/v1/echo_stream", params={"message": "hello"}
) as response:
assert response.status_code == 200
Rule of Thumb
Use this simple workflow:
- debug in local mode with Ray off
- test through HTTP with
framex.run(test_mode=True) - turn Ray on only for dedicated integration coverage
Advanced Usage
This section covers the parts of FrameX that matter once basic plugin registration is already working.
Use it when your service needs upstream API integration, distributed execution, tighter access control, or more operational tuning than the basic usage section provides.
Topics
-
Proxy Mode Bridge upstream HTTP services into the same FrameX surface with the built-in
proxyplugin. -
Integrating Ray Engine Run plugin deployments with Ray Serve when you need distributed execution.
-
Advanced Remote Calls & Non-Blocking Execution Use
@remote()for portable local or Ray-backed remote calls. -
Security & Authorization Control access to routes, docs, and APIs with FrameX authentication rules.
-
Concurrency & Ingress Configuration Tune ingress and execution behavior for higher traffic or lower latency requirements.
-
Proxy Function & Remote Invocation Use function-style proxying and remote invocation in the plugin model.
-
Simulating Network Communication (For Tests) Test network-facing behavior without depending on real remote services.
-
Monitoring & Tracing Add observability so request paths and plugin execution are easier to debug.
When To Read This Section
- after you can register and load basic plugins
- when plugins need to call each other through stable interfaces
- when upstream HTTP services should look like part of the same application
- when local execution is no longer enough and you want to move selected workloads to Ray
- when the service is growing and needs clearer operational boundaries
If you are still learning the basic runtime model, finish the basic usage section first.
System Proxy Plugin
The built-in proxy plugin has two core roles in FrameX, plus one more advanced extension point.
Core Role 1: Add Upstream Services To Your Own API Surface
If you already have an upstream FastAPI service or another FrameX service, the proxy plugin can pull that service into the current FrameX API surface.
That means callers can keep using the current FrameX service, while some capabilities are actually forwarded to another service behind the scenes.
Typical uses:
- keep an existing FastAPI service while introducing FrameX gradually
- aggregate multiple services behind one FrameX boundary
- expose upstream APIs through the same
/api/v1/...style surface as local plugins
Minimal Config
load_builtin_plugins = ["proxy"]
[server]
enable_proxy = true
[plugins.proxy]
white_list = ["/*"]
force_stream_apis = ["/api/v1/chat/stream"]
timeout = 600
[plugins.proxy.proxy_urls."http://127.0.0.1:9000"]
enable = ["/api/v1/*"]
disable = []
This proxy_urls mapping is the current full config form in the codebase.
The older list form still exists in the type definition, but if you want per-upstream control, use the dict form shown above.
How It Works
At startup, the proxy plugin reads the upstream /api/v1/openapi.json document, filters routes through the configured allow rules, and registers matching forwarding routes locally.
The current implementation supports:
- query parameters
- JSON request bodies
multipart/form-data- file uploads
- explicitly marked streaming APIs
Core Role 2: Preserve Privacy And Isolation In Multi-Plugin Collaboration
The second role is more important in larger systems.
In a multi-plugin or multi-team setup, one team may need to call another team's capability without having that plugin code locally.
In that case, the caller only depends on the API contract, not on the other team's implementation.
This gives you two benefits:
- implementation privacy: the upstream plugin code does not need to be shared locally
- codebase isolation: one team does not need to understand or import another team's plugin package
What This Looks Like
A plugin declares the API it depends on:
__plugin_meta__ = PluginMetadata(
name="invoker",
version=VERSION,
description="Calls another capability through a stable API",
author="you",
url="https://github.com/touale/FrameX-kit",
required_remote_apis=["/api/v1/base/version"],
)
Then it calls that API through the normal public interface:
from framex import call_plugin_api
version = await call_plugin_api("/api/v1/base/version")
If that API exists locally, FrameX uses the local plugin.
If it does not exist locally and proxy mode is enabled, the HTTP path can fall back to the proxy plugin and be forwarded to an upstream service.
That is the key point: the calling side does not need to change just because the real implementation lives somewhere else.
Proxy URL Rules
The proxy plugin supports two rule levels:
- global rules through
white_list - per-upstream rules through
proxy_urls.<base_url>.enableandproxy_urls.<base_url>.disable
Example:
[plugins.proxy]
white_list = ["/api/v1/*"]
[plugins.proxy.proxy_urls."http://127.0.0.1:9000"]
enable = ["/api/v1/*"]
disable = ["/api/v1/internal/*"]
Rule order in the code is:
- per-URL
disable - per-URL
enable - global
white_list
Streaming APIs
If an upstream route should stay on the streaming path, add it to force_stream_apis:
[plugins.proxy]
force_stream_apis = ["/api/v1/chat/stream"]
Those paths stay on the streaming code path instead of being handled as normal JSON responses.
Authentication
If the upstream service is another FrameX service and it has enabled auth.rules, you need to configure matching keys in plugins.proxy.auth.rules so the proxy plugin can call it successfully.
The proxy plugin uses these rules in two places:
- when fetching the upstream
/api/v1/openapi.json - when forwarding requests to protected upstream API paths
Example:
[plugins.proxy.auth]
rules = {
"/api/v1/openapi.json" = ["docs-key"],
"/api/v1/base/version" = ["service-key"],
"/api/v1/base/*" = ["service-key"]
}
That means:
- the proxy plugin uses
docs-keyto read the upstream OpenAPI document - it uses
service-keywhen forwarding protected API calls to/api/v1/base/versionor/api/v1/base/*
So if a cloud FrameX service protects its routes with auth.rules, the local service must mirror the needed upstream path rules in plugins.proxy.auth.rules and provide the corresponding keys.
For the full auth model and rule behavior on the upstream service itself, see Security & Authorization.
Advanced Use Cases
The proxy plugin also supports more advanced usage patterns beyond plain HTTP route forwarding.
One of them is function-style proxying and remote invocation. That topic is covered separately in Proxy Function & Remote Invocation.
Rule Of Thumb
Use the proxy plugin when you need one of these outcomes:
- expose an upstream FastAPI or FrameX service through your current FrameX API surface
- let one plugin call another team's capability without importing or shipping that plugin locally
- keep API names stable while the real implementation moves between local and remote services
Integrating Ray Engine
Ray is the optional distributed execution backend in FrameX.
Its role is to move the runtime from local process execution to Ray-backed execution, while keeping the same plugin model and API surface.
Core Role
When Ray is enabled, FrameX keeps the same plugin structure but changes the execution backend.
That means:
- plugin deployments run through Ray Serve
- the main FastAPI ingress is mounted through Ray Serve
- plugin code does not need to be rewritten just because the backend changes
In other words, Ray changes how the system runs, not how plugins are written.
Install Ray Support
Ray support is optional:
pip install "framex-kit[ray]"
If Ray is not installed and you enable use_ray = true, startup fails.
Enable Ray
Turn it on in configuration:
[server]
use_ray = true
dashboard_host = "127.0.0.1"
dashboard_port = 8260
You can also set the same values through CLI options or environment variables.
What Changes After Enabling Ray
When server.use_ray = true:
- FrameX switches from the local adapter to
RayAdapter - plugin deployments become Ray Serve deployments
- the main ingress is mounted through Ray Serve
What does not change:
PluginMetadata@on_register()@on_request(...)call_plugin_api(...)- plugin import paths and loading model
Example Config
load_builtin_plugins = ["echo"]
load_plugins = ["your_project.plugins.foo"]
[server]
host = "127.0.0.1"
port = 8080
use_ray = true
dashboard_host = "127.0.0.1"
dashboard_port = 8260
Ray Dashboard
When Ray is enabled, the dashboard is available at the configured dashboard host and port.
Example:
[server]
dashboard_host = "127.0.0.1"
dashboard_port = 8260
Constraints
If your codebase uses @remote(), enabling Ray changes how those calls execute at runtime. The detailed behavior is covered in Advanced Remote Calls & Non-Blocking Execution.
Rule Of Thumb
Use Ray when you need the same plugin model with a different execution backend.
Keep local mode when you are developing, debugging, or running lightweight tests.
Advanced Remote Calls & Non-Blocking Execution
The @remote() decorator has one core role in FrameX: move execution-heavy work behind a separate execution boundary while keeping the call site stable.
Core Role
A plugin route can be lightweight at the API layer, while the work behind it is not.
That work may involve:
- blocking synchronous libraries
- long-running computation
- legacy code that is not fully async
- logic that should keep the same call form in local mode and Ray mode
If that work stays inline inside the plugin handler, the plugin becomes harder to isolate and harder to move across execution backends later.
Even though enabling Ray ensures that one plugin's heavy workload will not block other plugins, it does not prevent blocking inside a single plugin itself.
If some part of your plugin code performs a blocking operation, such as time.sleep, long-running computation, or a non-async library call, the entire plugin instance may become unresponsive.
@remote() gives that work a stable execution boundary without changing the surrounding plugin API.
If you are defining a plugin API, use @on_request(...). If you are defining execution work behind that API, use @remote().
Typical Uses
Use @remote() when you want one of these outcomes:
- keep blocking or heavy work out of the main plugin handler path
- keep the same call form in both local mode and Ray mode
- isolate execution-heavy logic without changing the plugin API surface
- prepare code that runs locally today but may run through Ray later
What This Looks Like
A plugin handler stays focused on request handling:
from framex.plugin import on_request, remote
@remote()
def heavy_job(x: int) -> int:
return x * 2
@on_request("/api/v1/demo/run")
async def run_job(x: int):
return await heavy_job.remote(x)
That is the main pattern: keep the API-facing handler small, and move execution-heavy work behind @remote().
Supported Shapes
The current implementation supports:
- plain functions
- instance methods
- class methods
The stable call form is:
await func.remote(...)
Execution Behavior
The same .remote(...) call uses different execution paths depending on the active adapter.
In local mode:
- async functions are awaited directly
- sync functions run through
asyncio.to_thread(...)
In Ray mode:
- the callable is wrapped through
ray.remote(...) .remote(...)executes through Ray
That is the key point: the call interface stays stable while the backend changes.
Ray backend setup is covered in Integrating Ray Engine.
More Examples
Plain Function
from framex.plugin import remote
@remote()
def heavy_job(x: int) -> int:
return x * 2
result = await heavy_job.remote(21)
Instance Method
from framex.plugin import remote
class Worker:
@remote()
def total(self, values: list[int]) -> int:
return sum(values)
count = await Worker().total.remote([1, 2, 3])
Class Method
from framex.plugin import remote
class Worker:
@classmethod
@remote()
def scale(cls, x: int) -> int:
return x * 10
value = await Worker.scale.remote(2)
Rule Of Thumb
Use @remote() when you need one callable interface with backend-dependent execution.
Use @on_request(...) for APIs. Use @remote() for the work those APIs call behind the scenes.
Security & Authorization
This chapter shows how to configure authentication in FrameX.
In practice, there are three common cases:
- protect your own API routes
- protect
/docsand/api/v1/openapi.jsonwith OAuth login - let the built-in
proxyplugin call a protected upstream service
Protect API Routes
Use auth.rules when you want specific API paths to require authentication.
[auth]
rules = {
"/api/v1/base/version" = ["version-key"],
"/api/v1/base/*" = ["service-key"]
}
With this config:
/api/v1/base/versionacceptsversion-key- other routes under
/api/v1/base/*acceptservice-key - routes that do not match any rule stay public
Callers should send the key through the Authorization header.
curl -H 'Authorization: service-key' http://127.0.0.1:8080/api/v1/base/hello
The current implementation matches the header value directly. It does not add a Bearer prefix automatically.
If you use both an exact rule and a wildcard rule, the exact rule wins. Among wildcard rules, the longest matching prefix wins.
Protect Docs And OpenAPI With OAuth
Use auth.oauth when you want these routes to go through an OAuth login flow:
/docs/redoc/api/v1/openapi.json
Minimal config:
[auth]
rules = {
"/docs" = ["docs-key"],
"/redoc" = ["docs-key"],
"/api/v1/openapi.json" = ["docs-key"]
}
[auth.oauth]
base_url = "https://gitlab.example.com"
client_id = "your-client-id"
client_secret = "your-client-secret"
redirect_uri = "/oauth/callback"
app_url = "http://127.0.0.1:8080"
With this config:
- FrameX mounts the callback route at
redirect_uri app_url + redirect_uribecomes the callback URL sent to the OAuth provider- visiting
/docswithout a valid login redirects the user to the provider login page - after login, FrameX stores a
framex_tokencookie and redirects back to/docs
If you need to override provider endpoints directly, you can also set:
authorization_urltoken_urluser_info_url
If auth.oauth is not configured, this OAuth login flow is disabled.
Let Proxy Call A Protected Upstream Service
If you use the built-in proxy plugin to connect to another protected service, configure auth under plugins.proxy.auth.rules.
load_builtin_plugins = ["proxy"]
[server]
enable_proxy = true
[plugins.proxy]
white_list = ["/api/v1/*"]
[plugins.proxy.proxy_urls."http://127.0.0.1:9000"]
enable = ["/api/v1/*"]
disable = []
[plugins.proxy.auth]
rules = {
"/api/v1/openapi.json" = ["docs-key"],
"/api/v1/base/version" = ["service-key"],
"/api/v1/base/*" = ["service-key"]
}
Use this when the upstream service itself is protected and the local proxy still needs to:
- fetch the upstream
/api/v1/openapi.json - forward protected upstream API requests
- call the proxy-function endpoint used by advanced proxy invocation
One important detail from the current implementation: the proxy plugin uses only the first matched key for each protected path.
So if you configure multiple keys in one rule, the first one is the key that will actually be forwarded upstream.
For proxy-specific usage patterns, see System Proxy Plugin (Fastapi API Compatibility).
Rule Of Thumb
Use auth.rules to protect your own API routes.
Use auth.oauth when you want browser access to /docs, /redoc, or /api/v1/openapi.json to go through OAuth login.
Use plugins.proxy.auth.rules when your local service needs to call a protected upstream service through the built-in proxy plugin.
Concurrency & Ingress Configuration
This chapter explains where ingress and concurrency settings are configured in FrameX.
The important part is that FrameX has more than one config path, and they do not all affect the same deployment.
What To Configure
In the current codebase, ingress-related settings are configured in three places:
base_ingress_configserver.ingress_config- plugin-level kwargs passed to
@on_register(...)
These settings are forwarded through the adapter layer.
In practice, the most common field is max_ongoing_requests.
base_ingress_config: Global Default
base_ingress_config is the global default used when FrameX builds deployments.
base_ingress_config = { max_ongoing_requests = 10 }
This value is the starting point for both:
- the main API ingress
- plugin deployments
server.ingress_config: Main API Ingress Override
server.ingress_config applies only to the main APIIngress deployment.
If you leave it empty, FrameX now computes a default automatically from the number of loaded HTTP deployments.
The current rule is:
- start from
base_ingress_config.max_ongoing_requests - multiply it by the number of loaded HTTP deployments
- keep a floor of
base * 6
So with:
base_ingress_config = { max_ongoing_requests = 10 }
and about 30 loaded plugin deployments, the main API ingress default becomes 300.
If you want to override that behavior explicitly, set server.ingress_config yourself:
[server]
ingress_config = { max_ongoing_requests = 120 }
That explicit value wins over the adaptive default.
Plugin Ingress Settings
If a plugin needs its own ingress settings, pass them directly into @on_register(...).
from framex.plugin import BasePlugin, on_register
@on_register(max_ongoing_requests=20)
class MyPlugin(BasePlugin):
pass
The built-in proxy plugin uses exactly this pattern, but its values come from plugin config:
[plugins.proxy]
ingress_config = { max_ongoing_requests = 60 }
And then inside the plugin:
@on_register(**settings.ingress_config)
class ProxyPlugin(BasePlugin): ...
So if you want per-plugin concurrency control, configure it at the plugin level.
Using It With Ray
If you run FrameX with server.use_ray = true, ingress settings become worth tuning.
This is the mode where values like max_ongoing_requests matter most for the main API ingress and for plugin deployments.
A practical starting point is:
- leave
server.ingress_configempty and let FrameX size the main API ingress automatically - only add plugin-level ingress settings for plugins that are clearly hotter or heavier than the rest
- adjust
num_cpusseparately if the whole Ray runtime needs more CPU capacity
[server]
use_ray = true
num_cpus = 4
If you are still developing locally and not pushing concurrency yet, you usually do not need to tune these values first.
Using It In Local Mode
In local development, keep this simple.
Start with the defaults, and only add ingress settings after you have a concrete reason such as a hot plugin or a busy Ray deployment target later.
For most local debugging and feature work, these settings are not the first thing to optimize.
Rule Of Thumb
Use base_ingress_config for global defaults.
Leave server.ingress_config empty if you want FrameX to size the main API ingress automatically.
Set server.ingress_config explicitly when you want a fixed ingress limit for the whole service.
Use plugin-level @on_register(...) kwargs, or plugin config forwarded into @on_register(...), when one plugin needs different ingress behavior from the rest of the service.
Proxy Function & Remote Invocation
This feature is for one specific case: a function may run locally in one deployment, but must run on another FrameX service in another deployment.
FrameX lets you keep the same function call in code and decide through configuration whether that function runs locally or remotely.
When To Use It
Use proxy functions when a capability cannot or should not run in the local service.
Typical scenarios:
- the local service does not have MySQL or Redis access, but another FrameX service does
- the local service is restricted to outbound HTTP only, while another FrameX service can access internal networks
- sensitive logic should stay on the team-owned service instead of being copied into another codebase
- one team wants to call another team's internal capability without importing that implementation locally
In these cases, proxy functions let you keep the local call path stable while moving the real execution to another FrameX instance.
How It Differs From @on_request(...)
Use @on_request(...) when you want to expose an API route.
Use @on_proxy() when you want a function call to stay internal, but be able to run either:
- locally
- or on a remote FrameX service
The key difference is that proxy functions are configuration-driven.
The same function can:
- run locally if it is not listed in
plugins.proxy.proxy_functions - run remotely if it is listed there
That switch does not require changing the call site.
Minimal Example
from typing import Any
from framex.plugin import BasePlugin, on_proxy, on_register, register_proxy_func
@on_proxy()
async def build_report(job_id: str) -> dict[str, Any]:
return {"job_id": job_id, "status": "done"}
@on_register()
class ReportPlugin(BasePlugin):
async def on_start(self) -> None:
await register_proxy_func(build_report)
How To Configure It
Enable the built-in proxy plugin and declare the remote function under plugins.proxy.proxy_functions.
load_builtin_plugins = ["proxy"]
[server]
enable_proxy = true
[plugins.proxy.proxy_urls."http://remote-framex:8080"]
enable = ["/api/v1/*"]
disable = []
[plugins.proxy]
proxy_functions = { "http://remote-framex:8080" = ["your_module.build_report"] }
With this config:
build_report(...)stays the same in code- if the function name is listed in
proxy_functions, FrameX forwards it to the remote service - if it is not listed there, the function runs locally
Requirements
@on_proxy()only supports async functions- proxy-function calls only support keyword arguments
server.enable_proxy = trueandload_builtin_plugins = ["proxy"]must both be set- every URL used in
proxy_functionsmust also exist inproxy_urls - the remote service must also register the same proxy function during startup
Protected Remote Services
If the remote FrameX service protects the proxy-function endpoint, add an auth rule for /api/v1/proxy/remote under plugins.proxy.auth.rules.
[plugins.proxy.auth]
rules = {
"/api/v1/proxy/remote" = ["proxy-key"]
}
For the full auth model, see Security & Authorization.
Rule Of Thumb
Use @on_request(...) for public API routes.
Use @on_proxy() when you want the same internal function call to be able to run locally or on another FrameX service, depending on configuration.
Simulating Network Communication (For Tests)
This feature is designed exclusively for tests.
When writing unit tests or integration tests that involve HTTP calls, you should avoid calling real external services.
Instead, use pytest-vcr to record the first HTTP interaction and replay it in subsequent test runs.
Why Use It
- makes tests deterministic instead of depending on unstable external APIs
- makes tests faster by avoiding repeated network requests
- prevents accidental consumption of API quota or credentials
- keeps CI runs consistent without requiring real upstream services
VCR Configuration
Put these fixtures in tests/conftest.py:
import pytest
from framex.config import settings
@pytest.fixture(scope="module")
def vcr_config():
return {
"record_mode": "new_episodes", # record new calls only when missing
"filter_headers": ["authorization", "api-key"], # hide secrets
"ignore_hosts": ["testserver"], # don't record FastAPI TestClient
"match_on": ["uri", "method", "body", "path", "query"],
"allow_playback_repeats": True,
}
@pytest.fixture(scope="module")
def disable_recording(request): # noqa
return settings.test.disable_record_request
def before_record_request(request):
# Skip recording noisy or irrelevant endpoints
if all(ch not in request.path for ch in ["rerank", "minio"]):
return request
return None
def before_record_response(response):
if response["status"]["code"] != 200:
return None
return response
Writing A Test
Then annotate the test with @pytest.mark.vcr(...):
import pytest
from fastapi.testclient import TestClient
from framex.consts import API_STR
from tests.conftest import before_record_request, before_record_response
@pytest.mark.vcr(
before_record_request=before_record_request,
before_record_response=before_record_response,
)
def test_get_proxy_version(client: TestClient):
res = client.get(f"{API_STR}/base/version").json()
assert res["status"] == 200
assert res["data"]
On the first run, VCR records the HTTP interaction.
On later runs, the same response is replayed from the cassette, so the test does not need to call the real upstream service again.
Rule Of Thumb
Use pytest-vcr when a FrameX test depends on HTTP behavior and you want to keep the request path realistic while making repeated runs deterministic.
Monitoring & Tracing
FrameX supports two practical monitoring paths today: Sentry for application errors and traces, and Ray Dashboard for distributed execution visibility.
Sentry
Sentry is enabled through the sentry config block.
[sentry]
enable = true
dsn = "<your-sentry-dsn>"
env = "local"
debug = true
ignore_errors = []
lifecycle = "trace"
enable_logs = true
What Sentry Covers
- application errors and exceptions
- trace collection when
lifecycle = "trace" - optional log capture with
enable_logs = true - custom ignore rules through
ignore_errors
FrameX only initializes Sentry when enable, dsn, and env are all set.
Ray Dashboard
Ray Dashboard is available when the service runs with use_ray = true.
[server]
use_ray = true
dashboard_host = "127.0.0.1"
dashboard_port = 8260
When Ray starts, the dashboard is available at the configured host and port.
Log Noise Control
FrameX also keeps routine logs readable through the log and server config blocks.
[log]
simple_log = true
ignored_contains = ["GET /ping", "GET /health"]
[server]
excluded_log_paths = ["/api/v1/openapi.json"]
This helps keep health checks, docs traffic, and Ray noise out of the main request logs.
Notes
- Sentry environment names are combined with the adapter mode internally, so local and Ray runs are separated.
- The OpenAPI page includes a small runtime status block with uptime and version information.
- FrameX does not currently ship a separate metrics backend beyond Sentry and Ray Dashboard.
Development Guide
This guide is for contributors who want to work on FrameX locally and keep changes aligned with the current README and book chapters.
Getting Started
Create a local environment and install the project in editable mode:
uv sync --dev
If you want to verify the runtime quickly, start the built-in echo plugin:
framex run --load-builtin-plugins echo
Recommended Workflow
- Make your change in a feature branch.
- Keep code, examples, and docs consistent with the public API.
- Run the relevant tests before opening a pull request.
- Re-run formatting if a hook changes files.
The commands you will use most often are:
poe test-all
Dependency Management
Use uv to manage dependencies:
uv add requests
uv add pytest --group dev
uv remove requests
uv lock --upgrade-package requests
After dependency changes, sync the environment again:
uv sync --dev
Project Tasks
poe exposes the main project tasks:
poe help
Common tasks include:
style: format and fix style issueslint: run style and type checkstest: run the fast test settest-all: run the full test suitebuild: build source and wheel packagespublish: publish the package to PyPI
Commit Style
This project uses short, conventional commit messages:
<type>: <message>
Common types are feat, fix, docs, refactor, test, build, and chore.
Examples:
feat: add plugin config loading example
fix: handle proxy plugin response errors
docs: update quickstart for builtin plugins
Before You Open A PR
Make sure the following are true:
- the code runs locally
- tests pass
- formatting hooks pass
- docs and examples still match the current CLI behavior
Keeping changes small and focused makes review faster and reduces accidental regressions.
FAQ & Troubleshooting
uv sync --dev created .venv, but the shell is not activated
uv sync installs the environment, but it does not automatically source it.
Activate it manually:
source .venv/bin/activate
framex run starts, but my plugin is not loaded
Make sure you pass the correct load option.
Use --load-builtin-plugins for built-in plugins, or --load-plugins for your own plugin packages. Both options can be repeated.
framex run --load-builtin-plugins echo
framex run --load-plugins my_plugin --load-plugins another_plugin
pre-commit.ci reports files were modified by this hook
That means the hook reformatted files in CI.
Run the same formatter locally, commit the updated files, and push again:
pre-commit run --all-files
If the repo uses pre-commit.ci, the auto-fix only applies to writable pull request branches. It does not create a new pull request for you.
mypy complains about missing stubs
This usually means a third-party dependency does not ship type information.
There are two practical solutions.
Solution 1: Ignore That Dependency In Mypy
This is not the recommended option, but it is acceptable when no stub package exists.
Add a targeted ignore rule in mypy.ini or pyproject.toml.
For example, for mindforge:
[mypy-mindforge.*]
ignore_missing_imports = True
Avoid disabling missing-import checks globally. Keep the ignore rule scoped to the specific dependency.
Solution 2: Install The Stub Package
This is the recommended option when a matching type stub package exists.
For example:
uv add types-pytz --dev
uv add types-pyyaml --dev
Use this path for libraries such as:
pytz->types-pytzpyyaml->types-pyyaml
If a stub package exists, prefer installing it over adding an ignore rule.