Foxel
Menu
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:

json
{
  "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:

python
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:

text
/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:

typescript
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:

python
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

ExportPurpose
PROCESSOR_FACTORYRequired callable with no required arguments; returns an instance and is called during activation to inspect attributes
PROCESSOR_NAMEDisplay name, falling back to manifest name / type
SUPPORTED_EXTSRegistration metadata extensions; keep aligned with instance supported_exts
CONFIG_SCHEMAConfiguration form fields, usually referencing the class attribute
PROCESSOR_TYPEKeep 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 / caseContract
requires_input_bytes = TrueHost reads and passes file content; this is also the default
requires_input_bytes = FalseAll three arguments are still passed; input_bytes is b""
supports_directory = TrueA directory can be passed directly, with b"" and a VFS path
produces_file = TrueReturn file contents as bytes or a regular Response; the host saves to the requested destination
produces_file = FalseReturn 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

typescript
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.