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:
systemselects a registered adapter.v1is the supported custom-id version.actionselects 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.
| Adapter | Purpose | Standalone behavior |
|---|---|---|
skillopt | Manages SkillOpt runs, diffs, evaluations, review HTML, bundles, and candidate import/export artifacts. | Self-contained aside from configured local storage and optional train commands. |
image_gen | Provides 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_relay | Bridges 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
handleplus optional adapter attributes.
Discovery normalizes both forms into an AdapterSpec:
| Field | Required | Purpose |
|---|---|---|
system | yes | System name selected by system:v1:action custom IDs and used as the HTTP mount path. |
handle | yes | Dispatch callable receiving action: str, payload: list[str], and context: dict[str, Any]. |
router | optional | FastAPI APIRouter mounted by create_app() under /<system>. |
metadata | optional | Human/operator metadata surfaced by inventory and health tooling. |
readiness | optional | Callable used by health/doctor surfaces to report adapter readiness. |
lifespan | optional | Async context manager or lifespan callable run at app startup/shutdown with isolation. |
api_version | optional | Adapter contract version advertised by inventory tooling. |
capabilities | optional | Capability strings for humans and MCP clients. |
supported_versions | optional | Custom-id versions the adapter supports; default behavior includes v1. |
requires_auth | optional | Whether contributed HTTP routes require adapter bearer auth; defaults to True. |
source | optional | Origin 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:
AdapterSpecand handler types for explicit contracts.ok()anderr()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
- Scaffold an adapter:
aphrodite new-module hello_adapter
- 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")
- 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
- 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)forDispatchRouterregistration. Actionshealth,readiness, andstatusreport 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_TOKENis 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_PROVIDERandAPHRODITE_ACP_MODELare both set, that override wins. Otherwise the relay uses the spawned Hermes profile’s own current model; no provider/model default is forced. - The
acpPython client is imported lazily insideacp_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; requestcwdoverrides are ignored unlessAPHRODITE_ACP_ALLOW_CWD_OVERRIDE=trueand the requested directory is under the configured relay cwd. GET /acp/conversationssupportslimit/offsetpagination.POST /acp/conversations/{conversation_id}/turnsaccepts anIdempotency-Keyheader oridempotency_keypayload 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
incompleteflag 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.