On this page
Backend Development
Backend Python files are loaded inside the Foxel process. A package can provide routes, processors, or both, with or without a frontend. The current host project requires Python >=3.14.
Declare backend entries
Merge this fragment into a complete manifest and include both files in the archive:
{
"backend": {
"routes": [
{ "module": "backend/routes/api.py", "prefix": "/api/com.example.jsonviewer" }
],
"processors": [
{
"module": "backend/processors/format_json.py",
"type": "com.example.jsonviewer.format",
"name": "JSON Formatter"
}
]
}
}
module is a file path relative to the plugin root, not a dotted Python import path. The loader imports by location with a dynamic module name, so do not assume normal package-relative import behavior. Keep shared logic in the entry file initially; if splitting modules, define an explicit import strategy without modifying global sys.path.
FastAPI routes
Create backend/routes/api.py:
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from domain.auth import User, get_current_active_user
import json
router = APIRouter()
class ValidateRequest(BaseModel):
content: str
@router.post("/validate")
async def validate_json(
body: ValidateRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
):
try:
json.loads(body.content)
except json.JSONDecodeError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"valid": True}
The manifest prefix and decorator path are concatenated directly:
/api/com.example.jsonviewer + /validate
= POST /api/com.example.jsonviewer/validate
Foxel does not automatically add another /api or plugin key. Namespace prefixes to avoid conflicts. Frontend requests omit the /api supplied by the request client:
const result = await ctx.host.callApi<{ valid: boolean }>(
"/com.example.jsonviewer/validate",
{ method: "POST", json: { content: "{\"hello\":\"Foxel\"}" } },
);
The example uses the real get_current_active_user dependency. Plugin routes do not automatically inherit authentication or permission dependencies from the plugin management API; declare what your business logic requires. VFS operations also need path authorization. Prefer host VFS services and never pass an incoming VFS path straight to open() or os.walk().
Return plain JSON objects, or follow api/response.py for the host envelope and its frontend data unwrapping behavior. Raise HTTPException with appropriate status codes for failures.
File processors
Create backend/processors/format_json.py:
import json
from typing import Any
class JsonFormatProcessor:
name = "JSON Formatter"
supported_exts = ["json"]
config_schema = [
{"key": "indent", "label": "Indent", "type": "number", "default": 2},
]
produces_file = True
supports_directory = False
requires_input_bytes = True
async def process(
self, input_bytes: bytes, path: str, config: dict[str, Any]
) -> bytes:
indent = int(config.get("indent", 2))
if not 0 <= indent <= 8:
raise ValueError("indent must be between 0 and 8")
value = json.loads(input_bytes.decode("utf-8-sig"))
return (json.dumps(value, ensure_ascii=False, indent=indent) + "\n").encode("utf-8")
PROCESSOR_TYPE = "com.example.jsonviewer.format"
PROCESSOR_NAME = JsonFormatProcessor.name
SUPPORTED_EXTS = JsonFormatProcessor.supported_exts
CONFIG_SCHEMA = JsonFormatProcessor.config_schema
PROCESSOR_FACTORY = JsonFormatProcessor
Exports and registration
| Export | Purpose |
|---|---|
PROCESSOR_FACTORY | Required callable with no required arguments; returns an instance and is called during activation to inspect attributes |
PROCESSOR_NAME | Display name, falling back to manifest name / type |
SUPPORTED_EXTS | Registration metadata extensions; keep aligned with instance supported_exts |
CONFIG_SCHEMA | Configuration form fields, usually referencing the class attribute |
PROCESSOR_TYPE | Keep aligned with manifest; plugin registration actually uses manifest type |
Processor types are globally unique. Prefix them with the plugin key, such as com.example.jsonviewer.format; another owner claiming the same type causes activation to fail. Constructors should not start background tasks or perform lengthy work because preparation constructs a sample instance.
Arguments and results
Always use async process(self, input_bytes, path, config):
| Attribute / case | Contract |
|---|---|
requires_input_bytes = True | Host reads and passes file content; this is also the default |
requires_input_bytes = False | All three arguments are still passed; input_bytes is b"" |
supports_directory = True | A directory can be passed directly, with b"" and a VFS path |
produces_file = True | Return file contents as bytes or a regular Response; the host saves to the requested destination |
produces_file = False | Return serializable analysis data, such as a dictionary |
The current saving layer also accepts bytearray and strings; prefer bytes / Response. Do not return a dictionary with output_bytes: saving does not read that field. Use a buffered Response; do not assume StreamingResponse works with the current .body saving implementation.
The task's save_to / overwrite controls output paths, not a returned path field. Directory batching uses /processors/process-directory; file-producing processors require suffix when not overwriting. supports_directory means the processor itself handles a directory, which differs from the host scanning files in a batch.
Submit a processor task
const { task_id } = await ctx.host.callApi<{ task_id: string }>(
"/processors/process",
{
method: "POST",
json: {
path: ctx.filePath,
processor_type: "com.example.jsonviewer.format",
config: { indent: 2 },
save_to: "/documents/formatted.json",
overwrite: false,
},
},
);
This returns a task ID rather than the formatted file. Input and output paths are permission checked. Wait for completion before inspecting the output.
Query /tasks/queue/{task_id} through host.callApi for status: pending, running, success, or failed. Inspect result or error on completion. Stop any polling when the window unmounts.
Dependencies, data, and lifecycle
dependencies.python and dependencies.packages validate the host environment. They create no virtual environment and install no packages. Deployment operators must install missing packages in the Foxel environment before retrying.
Modules live in version directories. Use Path(__file__).parent for bundled resources, but keep persistent data outside those directories. There is currently no documented FOXEL_HOME or plugin data directory API; provide explicit storage configuration and plan for upgrades if persistence is needed.
Disabling removes route and processor registrations; updates switch versions. The current contract has no backend on_load / on_unload hooks, so module imports are not a lifecycle with guaranteed cleanup for persistent tasks.
Verification
Test routes by mounting the router in a FastAPI application and overriding authentication dependencies explicitly in tests. Test processor instances with asynchronous calls, valid output, and invalid JSON. Then install in real Foxel, inspect status, loaded_routes, and loaded_processors, and submit a processing task.
Implementation references: domain/plugins/loader.py, manager.py, domain/processors/base.py, registry.py, domain/virtual_fs/processing.py.
Next: build and package.