Skip to content

Docs — Module adapters

Module adapters

Aphrodite routes Discord component custom IDs and HTTP dispatch calls through aphrodite.router.DispatchRouter.

Custom ID format

The router expects this form:

system:v1:action:arg1:arg2

Parsing rules from aphrodite/router.py:

  • system selects a registered adapter.
  • v1 is the supported custom-id version.
  • action selects behavior inside the adapter.
  • Remaining colon-separated fields are passed as payload: list[str].
  • The adapter also receives a context: dict[str, Any] built by the caller.

A successful dispatch response includes ok, system, version, action, payload, and result. Unknown systems, unsupported versions, parsing errors, and adapter exceptions return structured ok: false responses instead of escaping through the HTTP boundary.

Bundled adapters

aphrodite.app.build_router() discovers adapter specs published under the aphrodite.adapters entry-point group, then registers the configured names from APHRODITE_MODULES. The default public set is image_gen,skillopt,acp_relay.

AdapterPurposeStandalone behavior
skilloptManages SkillOpt runs, diffs, evaluations, review HTML, bundles, and candidate import/export artifacts.Self-contained aside from configured local storage and optional train commands.
image_genProvides a dispatch status action and the /image/generate HTTP route for Codex-backed image generation.Live HTTP generation requires Hermes Codex/OpenAI OAuth from the private agent stack; no plain API-key environment path exists.
acp_relayBridges Aphrodite to an external ACP agent runtime and exposes both dispatch and /acp/* HTTP surfaces.Requires a working external Hermes/ACP runtime for real turns; fake transports can test the Aphrodite-owned pieces.

Adapters that bridge private Hermes plugins belong in the operator overlay, not the public module set. Custom modules can still be enabled by appending their system names with APHRODITE_MODULES=+name; the leading + appends to the built-in modules; a bare list replaces them — use bare only to intentionally reduce the set. Each system name must match an entry-point name. Unknown names fall back to a placeholder handler so startup remains deterministic.

Adapter contract

Aphrodite discovers third-party adapters from the aphrodite.adapters Python entry-point group. The entry-point name is the dispatch system name, and the loaded value may be either:

  • a bare handle(action, payload, context) callable; or
  • a module/object exposing handle plus optional adapter attributes.

Discovery normalizes both forms into an AdapterSpec:

FieldRequiredPurpose
systemyesSystem name selected by system:v1:action custom IDs and used as the HTTP mount path.
handleyesDispatch callable receiving action: str, payload: list[str], and context: dict[str, Any].
routeroptionalFastAPI APIRouter mounted by create_app() under /<system>.
metadataoptionalHuman/operator metadata surfaced by inventory and health tooling.
readinessoptionalCallable used by health/doctor surfaces to report adapter readiness.
lifespanoptionalAsync context manager or lifespan callable run at app startup/shutdown with isolation.
api_versionoptionalAdapter contract version advertised by inventory tooling.
capabilitiesoptionalCapability strings for humans and MCP clients.
supported_versionsoptionalCustom-id versions the adapter supports; default behavior includes v1.
requires_authoptionalWhether contributed HTTP routes require adapter bearer auth; defaults to True.
sourceoptionalOrigin information for diagnostics.

Return dictionaries in the canonical result dialect:

{"ok": True, "message": "done"}
{"ok": False, "error": "what failed"}

The dispatch wrapper adds system, version, action, payload, and result. Router failures, unsupported versions, unknown systems, and adapter exceptions return structured ok: false responses instead of escaping through the HTTP boundary.

Adding an adapter

The quickest path is to let Aphrodite scaffold the package:

aphrodite new-module my_module
export APHRODITE_MODULES=+my_module  # leading + appends to the built-in modules; a bare list replaces them — use bare only to intentionally reduce the set
aphrodite modules
aphrodite dispatch-test my_module:v1:ping

aphrodite new-module my_module creates a ready-to-edit my_module/ folder with my_module.py, pyproject.toml, generated tests, and next-step commands. The generated package publishes an aphrodite.adapters entry point; use examples/hello_adapter/ as a copy-paste worked reference when you want to compare the scaffold with a complete tiny adapter.

Important: install adapters into the same Python environment Aphrodite runs in, or discovery will not find them. The new-module next steps print the exact .../python -m pip install -e ... command for your environment; after the one-line installer, manual installs can use ~/.local/share/aphrodite/venv/bin/python -m pip install -e <module>. Run aphrodite modules afterward to confirm the adapter is active.

Custom modules can be enabled by appending their system names with APHRODITE_MODULES=+name; the leading + appends to the built-in modules; a bare list replaces them — use bare only to intentionally reduce the set. Each system name must match an entry-point name. Unknown names fall back to a placeholder handler so startup remains deterministic.

Entry points: bare callable or module object

For a dispatch-only adapter, point the entry point directly at a callable:

[project.entry-points."aphrodite.adapters"]
my_adapter = "your_pkg.your_module:handle"
from typing import Any

from aphrodite.sdk import err, ok


def handle(action: str, payload: list[str], context: dict[str, Any]) -> dict[str, Any]:
    if action == "ping":
        return ok(action=action, message="my_adapter is alive")
    if action == "echo":
        return ok(action=action, echo=payload[0] if payload else "")
    return err(f"unknown action: {action}", action=action)

For richer adapters, point the entry point at a module/object and export optional attributes next to handle:

[project.entry-points."aphrodite.adapters"]
my_adapter = "your_pkg.your_module"
from contextlib import asynccontextmanager
from typing import Any

from fastapi import APIRouter

from aphrodite.sdk import err, ok

router = APIRouter()
metadata = {"description": "example adapter"}
capabilities = ("dispatch", "http")
supported_versions = ("v1",)
requires_auth = True


def handle(action: str, payload: list[str], context: dict[str, Any]) -> dict[str, Any]:
    if action == "ping":
        return ok(message="pong")
    return err(f"unknown action: {action}")


@router.get("/hello")
def hello() -> dict[str, Any]:
    return ok(message="hello from my_adapter")


def readiness() -> dict[str, Any]:
    return ok(ready=True)


@asynccontextmanager
async def lifespan(app):
    yield

HTTP routes and auth

When an adapter exposes router, create_app() mounts it under /<system>. For the example above, GET /my_adapter/hello reaches the contributed route.

Adapter HTTP routes require Authorization: Bearer $APHRODITE_ADAPTER_AUTH_TOKEN by default. If a route should be public, export requires_auth = False from the module/object or provide an AdapterSpec with requires_auth=False. Keep that opt-out rare and explicit: the default fails closed with 503 when auth is required but APHRODITE_ADAPTER_AUTH_TOKEN is unset.

Mount failures are quarantined in app.state.adapter_quarantine so one broken adapter does not prevent the service from starting. aphrodite modules and aphrodite doctor surface load, lint, mount, lifespan, and dependency issues.

Lifespan hooks

Adapters may export lifespan for startup/shutdown work such as opening pools, warming caches, or validating external dependencies. Each adapter lifespan runs with failure isolation and the timeout from APHRODITE_ADAPTER_LIFESPAN_TIMEOUT (default 30 seconds). A failing or timed out lifespan quarantines that adapter’s lifecycle failure instead of taking down unrelated adapters.

SDK and testing kit

Use aphrodite.sdk for the public authoring helpers:

  • AdapterSpec and handler types for explicit contracts.
  • ok() and err() for canonical result dictionaries.
  • path helpers that match Aphrodite’s runtime layout.

Use aphrodite.testing for local tests:

  • dispatch_once(...) to exercise one custom-id dispatch.
  • make_adapter_app(...) to build a FastAPI app around an adapter.
  • make_adapter_client(...) to call contributed routes in-process.
  • assert_result_ok(...) to assert canonical success results.

Dev-runner

During development, run a local adapter without installing it first:

aphrodite run --adapter ./my_module

The dev-runner loads the adapter from the supplied path and serves it through the same app factory path as installed adapters, so dispatch, router mounting, auth, lifespan isolation, and diagnostics match normal runtime behavior.

Supply-chain allowlist

Set APHRODITE_TRUSTED_ADAPTERS to a comma-separated list of entry-point names that are allowed to load. Use it for production or shared environments where third-party packages may be installed in the same Python environment. Keep the list aligned with APHRODITE_MODULES; configured-but-untrusted adapters are reported by inventory/doctor surfaces instead of silently loading.

MCP reachability

When the optional MCP server is installed, discovered adapters are reachable through the aphrodite_adapters inventory tool and the aphrodite_dispatch tool. MCP clients see the same configured adapter set and the same canonical success/failure result dialect as HTTP and CLI dispatch.

End-to-end tutorial: scaffold, add a route, run locally

  1. Scaffold an adapter:
aphrodite new-module hello_adapter
  1. In the generated module, add a router:
from typing import Any

from fastapi import APIRouter

from aphrodite.sdk import ok

router = APIRouter()


@router.get("/hello")
def hello() -> dict[str, Any]:
    return ok(message="hello")
  1. Run the adapter from its path without installing it:
export APHRODITE_MODULES=+hello_adapter
export APHRODITE_TRUSTED_ADAPTERS=hello_adapter
export APHRODITE_ADAPTER_AUTH_TOKEN=dev-secret
aphrodite run --adapter ./hello_adapter
  1. Hit the contributed route with the bearer token:
curl -fsS \
  -H "Authorization: Bearer dev-secret" \
  http://127.0.0.1:9079/hello_adapter/hello

Expected adapter result:

{"ok": true, "message": "hello"}

Edit & debug loop

After scaffolding, edit my_module/my_module.py and add an action while keeping the generated ping branch:

def handle(action: str, payload: list[str], context: dict[str, Any]) -> dict[str, Any]:
    if action == "ping":
        return {"ok": True, "action": action, "message": "my_module is alive"}
    if action == "echo":
        return {"ok": True, "action": action, "echo": payload[0] if payload else ""}
    return {"ok": False, "action": action, "error": f"unknown action: {action}"}

Then reinstall the package into the same environment Aphrodite uses and dispatch one custom id:

~/.local/share/aphrodite/venv/bin/python -m pip install -e my_module
export APHRODITE_MODULES=+my_module  # leading + appends to the built-in modules; a bare list replaces them — use bare only to intentionally reduce the set
aphrodite modules
aphrodite dispatch-test my_module:v1:echo:hello

Expected JSON shape:

{
  "ok": true,
  "system": "my_module",
  "version": "v1",
  "action": "echo",
  "payload": ["hello"],
  "result": {
    "ok": true,
    "action": "echo",
    "echo": "hello"
  }
}

If dispatch-test exits nonzero, read the printed JSON first; router failures and adapter results with "ok": false both make the command fail.

Troubleshooting

If dispatch shows ok: false with an error that the module adapter is configured but not installed, you likely installed it into a different Python environment than the one running Aphrodite. Run aphrodite modules to compare configured, discovered, active, missing, available, quarantined, and untrusted adapters.

Third-party packages and private-overlay adapters register through the aphrodite.adapters group. Aphrodite’s public tree never imports them directly, which keeps the NO_CORE_POLICY / private-overlay fresh-clone rule intact. The native trio (image_gen, skillopt, and acp_relay) is registered exactly this way in Aphrodite’s own pyproject.toml.

Keep adapter boundaries narrow: Aphrodite should call public plugin/runtime APIs and should not patch the external runtime core.

Image generation auth

aphrodite/modules/image_gen.py does not read OPENAI_API_KEY or any other plain API-key environment variable for the HTTP route. /image/generate calls _build_codex_client(), which reads a Codex/OpenAI OAuth token through the private Hermes agent.auxiliary_client stack and constructs:

openai.OpenAI(
    api_key=token,
    base_url="https://chatgpt.com/backend-api/codex",
    ...
)

If that private OAuth token is unavailable, the route returns an auth_required error and performs no generation. The standalone integration path is programmatic: import generate_image and pass a client you own, for example generate_image(payload, client=<openai.OpenAI instance>).

Model selection can still be influenced with APHRODITE_IMAGE_GEN_MODEL or OPENAI_IMAGE_MODEL, but those variables select quality/model only; they do not authenticate the OpenAI/Codex client.

ACP relay

aphrodite/modules/acp_relay.py bridges Aphrodite to an external ACP agent runtime while Aphrodite owns the conversation database and HTTP boundary.

Public surfaces:

  • handle(action, payload, context) for DispatchRouter registration. Actions health, readiness, and status report relay readiness. Other actions currently return a handled-false response that points callers to the relay HTTP routes.
  • a contributed FastAPI router mounted by the adapter route seam under its system path, with relay conversation and turn endpoints protected by the relay’s own optional bearer token when APHRODITE_ACP_AUTH_TOKEN is set.
  • AcpRelay, ConversationStore, and configuration helpers used by the HTTP router and tests.

Runtime behavior:

  • Conversation metadata, turns, and successful idempotent turn responses are stored in SQLite owned by Aphrodite.
  • The real transport spawns hermes -p <profile> acp, creates or resumes an ACP session, explicitly selects the configured engine, and drives one ACP turn.
  • If APHRODITE_ACP_PROVIDER and APHRODITE_ACP_MODEL are both set, that override wins. Otherwise the relay uses the spawned Hermes profile’s own current model; no provider/model default is forced.
  • The acp Python client is imported lazily inside acp_transport; environments that do not install the optional client can still import the module and use tests/fake transports.
  • Default profile, provider, model, binary, working directory, database path, turn timeout, optional auth token, profile allowlist, cwd override gate, and headless approval/hook toggles are configurable with APHRODITE_ACP_* environment variables.
  • Conversation creation can be constrained with APHRODITE_ACP_ALLOWED_PROFILES; request cwd overrides are ignored unless APHRODITE_ACP_ALLOW_CWD_OVERRIDE=true and the requested directory is under the configured relay cwd.
  • GET /acp/conversations supports limit/offset pagination. POST /acp/conversations/{conversation_id}/turns accepts an Idempotency-Key header or idempotency_key payload field.
  • Readiness includes executable, cwd, database-writability, and ACP-library checks. Transport failures map to 502; stale external ACP sessions are replaced with fresh sessions, losing only the upstream ACP context.
  • Turn responses include an incomplete flag for non-end stop reasons. The relay is intentionally text-only: assistant/thought text is retained, while non-text ACP content blocks are ignored.

Default relay settings in the current code are profile forge and timeout 240.0 seconds. Provider and model are unset by default, so the relay uses the Hermes profile’s configured engine unless APHRODITE_ACP_PROVIDER and APHRODITE_ACP_MODEL are both set. Auto-approve and accept-hooks toggles default to true so headless forge turns continue to run without prompting.