A live, browsable reference is at /docs on any running instance (Scalar UI generated from utoipa annotations).
This page is a hand-written summary of the endpoints worth knowing. The Scalar UI is the authoritative spec.
Every /comp/* and /bff/v1/* endpoint requires Authorization: Bearer <Supabase JWT>. The JWT must be HS256-signed against SUPABASE_JWT_SECRET. The sub claim must be a UUID; that becomes the user_id for the request.
/healthz and /docs are public.
Liveness. No auth.
curl http://localhost:8080/healthz{
"status": "ok",
"service": "eros-engine",
"version": "0.3.1",
"timestamp": "2026-05-05T19:06:05.309302232+00:00"
}List active persona genomes. Auth required.
curl -H "Authorization: Bearer $JWT" \
http://localhost:8080/comp/personas{
"personas": [
{
"id": "11d6a45a-1fd9-4fe6-a943-3f049035eb68",
"name": "Aria",
"system_prompt": "…",
"tip_personality": "warm-but-reserved",
"avatar_url": "https://avatars.etherfun.xyz/aria.png",
"art_metadata": { "age": 27, "mbti": "INFJ", "model": "x-ai/grok-4-fast", … },
"is_active": true
}
]
}Open a new chat session against a persona genome. The server creates a persona_instance for (genome_id, jwt_user_id) if it doesn't already exist, then a chat_session referencing that instance.
curl -X POST -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
-d '{"genome_id":"11d6a45a-1fd9-4fe6-a943-3f049035eb68"}' \
http://localhost:8080/comp/chat/start{
"session_id": "5f7e…",
"persona_name": "Aria",
"is_new": true
}is_new=false if you call /start again with the same genome_id for the same user — the engine resumes the existing session rather than creating a duplicate.
Streaming chat turn. Returns text/event-stream with the
meta → delta* → done → final state machine described in the
SSE streaming chat 0.2 design spec.
The body must include client_msg_id (26..36 ASCII-printable chars,
any UUID or ULID). Replays of the same (session_id, client_msg_id) within
24 h reconstruct the original frames from the database without re-calling
OpenRouter.
curl -N -X POST \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
-d '{"content":"hi","client_msg_id":"01J3333333333333333333333A"}' \
http://localhost:8080/comp/chat/<session_id>/message/streamSample frames (one JSON object per data: line):
data: {"type":"meta","message_id":"01J...","action_type":"reply","model":"x-ai/grok-4-fast"}
data: {"type":"delta","message_id":"01J...","content":"你好"}
data: {"type":"done","message_id":"01J...","truncated":false,"usage":{"prompt_tokens":12,"completion_tokens":4,"total_tokens":16},"generation_id":"gen-abc"}
data: {"type":"final","lead_score":0.42,"should_show_cta":false,"agent_training_level":0.18}
Concurrent active streams per user are capped at 3. The keep-alive heartbeat
(: ping) is emitted every 15 s so reverse-proxies don't time out the
idle connection.
Pre-stream errors (HTTP 4xx/5xx before the first SSE byte) carry a JSON
body with code, message, user_message and — for
409 duplicate_in_progress — an original_user_message_id. See the
spec
for the full code table.
Once the first SSE byte has been written, terminal failures arrive as an
in-band error frame and the stream closes; the HTTP response has already
committed 200 OK.
Optional: tier selection. The body may include a tier string —
type String, regex ^[a-z0-9_]{1,32}$ (returns 400 if malformed).
Selects the per-tier model and allow_traits from model_config.toml
([tasks.chat_companion.tiers.<tier>]). An unknown or absent tier falls
back to the task default block (a warn is logged). Example:
curl -N -X POST -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
-d '{
"content": "hi",
"client_msg_id": "01J3333333333333333333333A",
"tier": "gold"
}' \
http://localhost:8080/comp/chat/<session_id>/message/streamOptional: per-request prompt traits. The body may include a
prompt_traits array — see prompt-traits.md. Example:
curl -N -X POST -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
-d '{
"content": "hi",
"client_msg_id": "01J3333333333333333333333A",
"prompt_traits": [
{"tag": "nsfw_boost", "text": "<your injection text here>"}
]
}' \
http://localhost:8080/comp/chat/<session_id>/message/streamLimits: ≤ 8 entries, tag matches [a-z0-9_]{1,32}, text ≤ 2000 chars
(non-blank). Violations return 400 BadRequest as a pre-stream error.
Optional: memory injection scope. The body may include a memory_scope
string to control which memory layers are injected into the prompt. Accepted
values:
| Value | Injected |
|---|---|
full |
Full user profile (including intimate fields) + relationship memory |
neutral_and_relationship |
Neutral profile (city/occupation/MBTI only) + relationship memory (default) |
relationship_only |
Relationship memory only; no profile |
neutral_only |
Neutral profile only; no relationship memory |
insights_only |
Full user profile only (intimate fields included); no relationship memory |
none |
No memory injection |
Important (#40 mitigation): The default
neutral_and_relationshipis intentionally narrower than the pre-#40 behavior (which injected everything). Omittingmemory_scopeis not equivalent to the old behavior — it applies the narrowed default. Usefullexplicitly if you need the full-injection behavior.
Optional: affinity injection scope. The body may include an
affinity_scope value to control which of the six affinity axes are injected
into the prompt. Accepted values:
- Named presets:
"bond"(default) — warmth + intimacy + tension;"chemistry"— trust + intrigue + patience;"bond_and_chemistry"/"full"— all six axes;"none"— no affinity injection. - Axis array: any subset of
["warmth", "trust", "intrigue", "intimacy", "patience", "tension"].
Important (#40 mitigation): The default
bond(3 axes) is intentionally narrower than the pre-#40 behavior (which injected all six axes). Omittingaffinity_scopeis not equivalent to the old behavior. Use"bond_and_chemistry"or"full"explicitly if you need all axes.
Example using both fields:
curl -N -X POST -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
-d '{
"content": "hi",
"client_msg_id": "01J3333333333333333333333A",
"memory_scope": "full",
"affinity_scope": "bond_and_chemistry"
}' \
http://localhost:8080/comp/chat/<session_id>/message/streamOptional: OpenRouter audit passthrough. The body may include an
audit object that rides directly to OpenRouter as wire-level user /
session_id / metadata — see llm-audit.md. Example:
curl -N -X POST -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
-d '{
"content": "hi",
"client_msg_id": "01J3333333333333333333333A",
"audit": {
"user": "u_<hash>",
"session_id": "conv_xyz",
"metadata": { "feature": "chat", "plan": "pro" }
}
}' \
http://localhost:8080/comp/chat/<session_id>/message/streamCaps: audit.user and audit.session_id ≤ 256 chars; audit.metadata
≤ 16 keys, key matches [A-Za-z0-9_.-]{1,64}, value is a string ≤ 512
chars. Violations return 400 BadRequest as a pre-stream error.
Paginated message history, newest first.
{
"messages": [
{ "id": "…", "role": "assistant", "content": "Bishop.", "sent_at": "…" },
{ "id": "…", "role": "user", "content": "hi…", "sent_at": "…" }
]
}role ∈ user | assistant | gift_user | system_error.
All chat sessions for user_id. The path's user_id MUST match the JWT's user_id; otherwise 403.
Current companion_insights JSONB plus a weighted training_level. Same user_id equality check as above.
{
"insights": {
"city": "Hong Kong",
"occupation": "graphic designer",
"interests": ["jazz", "long walks"],
"mbti_guess": "INFP"
},
"training_level": 0.42
}training_level is a weighted score across nine fields (city 0.05, occupation 0.05, interests 0.10, mbti_guess 0.15, love_values 0.15, emotional_needs 0.15, life_rhythm 0.10, personality_traits 0.15, matching_preferences 0.10). Weights sum to 1.0.
Apply affinity deltas from an out-of-band event (a virtual gift, a reaction, anything you want to model as "this user did something nice"). The route writes a chat_messages row with role='gift_user' and applies the deltas via the affinity persistence path.
curl -X POST -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
-d '{
"deltas": {"warmth": 0.05, "intimacy": 0.03, "tension": -0.02},
"label": "rose",
"metadata": {"source": "frontend-shop", "amount": 100}
}' \
http://localhost:8080/comp/chat/<session_id>/event/giftThe gift route does not invoke an LLM reaction in v0.1 (reply is null). The persona acknowledges the gift on the next user turn, where the new affinity state shapes the reply. A synchronous-reaction variant is a future enhancement.
List all gift events on this session, paginated.
Live 6-dim vector + ghost stats + relationship label. Gated by EXPOSE_AFFINITY_DEBUG=true env var; returns 404 when disabled.
{
"warmth": 0.42,
"trust": 0.28,
"intrigue": 0.61,
"intimacy": 0.15,
"patience": 0.55,
"tension": 0.18,
"ghost_streak": 0,
"total_ghosts": 0,
"relationship_label": "stranger",
"updated_at": "2026-05-05T19:42:00.000000Z"
}Production deploys typically keep this off (the affinity vector is part of the magic — exposing it ruins the illusion). Turn it on if your frontend wants to render a live radar of the vector.
A frontend-shaped mirror of selected /comp/* routes for first-party
clients. Same Supabase JWT auth and the same per-user ownership checks as
the canonical routes — only the response shape differs (slimmer DTOs,
bundled payloads). Canonical /comp/* routes are never reshaped to fit a
frontend; a BFF route is added alongside instead. Three routes exist today.
Cold-mount bundle: resolves (or creates) the session and returns its
recent history in one round-trip, collapsing the frontend's separate
start + history calls. For the same user + input it resolves to the
exact same session as the canonical POST /comp/chat/start.
The body is the canonical start body plus one BFF-only field:
genome_id/instance_id— identify the persona (same as canonical).is_demo— optional, same as canonical.history_limit— optional bundled-history page size; default 50, capped at 50.
{
"session_id": "5f7e…",
"instance_id": "…",
"persona_name": "Aria",
"is_new": false,
"history": [
{ "id": "3cc06c53-…", "client_msg_id": "c_abc", "role": "user", "content": "hello", "sent_at": "…" },
{ "id": "9f2e7a10-…", "client_msg_id": null, "role": "assistant", "content": "hi back", "sent_at": "…" }
]
}Affinity is intentionally not bundled here — the frontend reads it
separately (see the affinity event route below), which keeps bootstrap
independent of EXPOSE_AFFINITY_DEBUG.
Slim history projection for the chat screen: id / client_msg_id /
role / content / sent_at (no extracted_facts). id is the
chat_messages row primary key (UUID); client_msg_id is the id the FE
sent during streaming (null for rows that never carried one, e.g.
assistant turns). Same auth, ownership check, and
limit ∈ [1, 50] clamp as the canonical history route. Intentional
divergence: the default limit is 50 (the canonical route defaults to 20),
because the BFF exists for a cold mount that wants a full backscroll in one
round-trip.
{
"session_id": "…",
"messages": [
{ "id": "3cc06c53-…", "client_msg_id": "c_abc", "role": "user", "content": "alpha", "sent_at": "…" },
{ "id": "9f2e7a10-…", "client_msg_id": null, "role": "assistant", "content": "beta", "sent_at": "…" }
],
"total": 2
}total is the count of messages in this response (== messages.len()),
not the grand total of rows in the session.
Latest user-turn affinity delta (post-EMA), for per-turn frontend
observation. Unlike the canonical /comp/affinity/{session_id} debug
route, this is not gated by EXPOSE_AFFINITY_DEBUG (the frontend owns
this surface) — but it is still JWT + ownership checked.
{
"session_id": "…",
"event": {
"event_id": "…",
"event_type": "message",
"effective_deltas": {
"warmth": 0.03, "trust": 0.01, "intrigue": 0.0,
"intimacy": 0.0, "patience": 0.0, "tension": -0.01
},
"created_at": "…"
}
}event is null when there is no user-turn event yet (brand-new session,
or only time-decay), or when the latest event predates affinity migration
0014. event_type ∈ message | gift | proactive | ghost; a ghost turn
reports all-zero effective_deltas.
All errors are JSON with {"error": "<code>", "message": "<human-readable>"}:
| Status | Code | When |
|---|---|---|
| 400 | bad_request |
Malformed body, invalid UUID, missing required field |
| 401 | unauthorized |
Missing / malformed / expired / wrong-secret JWT |
| 403 | forbidden |
Path-user vs JWT-user mismatch, or trying to read a session you don't own |
| 404 | not_found |
Unknown session / persona / message id |
| 500 | internal |
Anything else (DB error, LLM API error, etc.) |
Mounted at /s2s/* and gated by HMAC-SHA256, not the Supabase JWT layer.
Intended exclusively for eros-marketplace-svc; see
deploying.md for env vars.
The OpenAPI spec at /docs is the authoritative reference; this section
is a quick orientation.
Four routes:
POST /s2s/ownership/upsert— apply a single ownership change (NFT bought / sold).GET /s2s/ownership/since?cursor_ts=&cursor_pk=&limit=— keyset-paginated pull of recent ownership rows.POST /s2s/wallets/upsert— apply a single wallet-link change (user linked / unlinked a wallet).GET /s2s/wallets/since?cursor_ts=&cursor_pk=&limit=— keyset-paginated pull of recent wallet-link rows.
Example upsert bodies:
// POST /s2s/ownership/upsert
{
"asset_id": "<base58 32-byte>",
"persona_id": "<base58 32-byte>",
"owner_wallet": "<base58 32-byte>",
"source_updated_at": "2026-05-13T08:00:00Z"
}// POST /s2s/wallets/upsert
{
"user_id": "11d6a45a-1fd9-4fe6-a943-3f049035eb68",
"wallet_pubkey": "<base58 32-byte>",
"linked": true,
"source_updated_at": "2026-05-13T08:00:00Z"
}Each request must carry:
x-s2s-timestamp— RFC3339,±5 minskew tolerated.x-s2s-signature— hex HMAC-SHA256 over the canonical signing string, usingMARKETPLACE_SVC_S2S_SECRET.
The canonical signing string is a five-line ASCII layout (see
crates/eros-engine-server/src/auth/s2s.rs for the authoritative
definition and helper functions):
METHOD\n
path\n
canonical_query\n
timestamp\n
body_sha256_hex
where canonical_query is the request's query string with &-separated
pairs sorted lexicographically (empty if no query), and body_sha256_hex
is the lowercase hex SHA-256 of the raw request body (empty body still
hashes to the SHA-256 of zero bytes). Body is buffered up to 1 MiB; larger
requests are rejected with 413 without computing the hash.
During secret rotation both MARKETPLACE_SVC_S2S_SECRET and
MARKETPLACE_SVC_S2S_SECRET_PREVIOUS are accepted for inbound; outbound
calls always sign with the current secret only.
crates/eros-engine-server/src/routes/companion.rs— handler implementationscrates/eros-engine-server/src/routes/bff/companion.rs— BFF/bff/v1/comp/chat/*crates/eros-engine-server/src/routes/bff/affinity.rs— BFF/bff/v1/comp/affinity/*crates/eros-engine-server/src/routes/debug.rs— affinity debug routecrates/eros-engine-server/src/routes/health.rs—/healthzcrates/eros-engine-server/src/routes/s2s.rs—/s2s/*handlerscrates/eros-engine-server/src/auth/s2s.rs— HMAC canonical signing layoutcrates/eros-engine-server/src/openapi.rs— Scalar UI spec metadata