Internal API
The loopback-bound HMAC seam between Laravel and the FastAPI agent worker. Three endpoints. Bound to 127.0.0.1 only. Every request signed. The full contract document lives at docs/architecture/internal-api.md in the repo.
Where the full contract lives
The authoritative document is at docs/architecture/internal-api.md in the source tree. The JSON Schemas for the AgentTurn job and the agent response live at infra/contracts/agent-turn.schema.json and infra/contracts/agent-turn-response.schema.json; both PHP and Python code-generate from the same schemas and CI gates parity.
The shape
The FastAPI agent worker calls back into Laravel for two things: load the turn context and post the result. A third endpoint exists for escalation when the agent gives up.
| Method | Path | Used by |
|---|---|---|
| GET | /_internal/conversations/:id/turn-context | FastAPI loads recent messages, scope, persona, current phase. |
| POST | /_internal/conversations/:id/turn-result | FastAPI posts the LLM output: phase, reply, intents, tokens, cost. |
| POST | /_internal/conversations/:id/escalate | FastAPI gives up after structured-output retries; switch to human handoff. |
HMAC signature
Every request carries an X-Foyer-Internal-Sig header that is the HMAC-SHA256 of the raw request body under a shared secret. The secret lives in environment as FOYER_INTERNAL_SECRET and is the same value on both sides. Constant-time comparison on receive.
# In the FastAPI worker
import hashlib, hmac, os
def sign(body: bytes) -> str:
secret = os.environ["FOYER_INTERNAL_SECRET"].encode()
return hmac.new(secret, body, hashlib.sha256).hexdigest()# In Laravel middleware
$expected = hash_hmac('sha256', $request->getContent(), config('foyer.internal_secret'));
if (! hash_equals($expected, $request->header('X-Foyer-Internal-Sig'))) {
abort(403, 'invalid signature');
}Loopback binding
The internal route group is registered behind two middlewares — internal.loopback and internal.hmac. The first rejects with 403 if the request did not originate from 127.0.0.1 or ::1; the second validates the signature. Both fail closed. The router does not expose these routes to the public webserver — the Apache vhost on the API host does not proxy /_internal, so even a misconfiguration is one extra check away from a leak.
Error responses
RFC 7807 problem documents, consistent with the public surface:
{
"type": "https://foyer.philiprehberger.com/problems/invalid-signature",
"title": "Invalid internal signature",
"status": 403,
"detail": "X-Foyer-Internal-Sig header did not match the computed signature."
}The agent worker treats any 4xx as terminal — do not retry on signature mismatch, the secret is misconfigured. 5xx is retryable with exponential backoff.
Why this seam is the most failure-prone part
Two processes, two languages, one DB, one queue. The internal API is the single seam where a contract drift, a clock skew, or a misrouted request can fail silently. The defenses are layered — schema versioning on the job, schema codegen on both sides, contract tests in CI, HMAC on every request, loopback binding, RFC 7807 errors — because any one of them on its own is too thin a guard. The doc at docs/architecture/internal-api.md is the place to read before changing anything across the seam.