Scout Agent — agentic recommendations
LangGraph · Neon pgvector · Lambda Function URL · SSE · FastAPI · CloudFront
What it does
A conversational transfer-recommendation agent on top of the FPL Pulse pipeline. The user pastes their FPL team ID; the agent reads curated enrichment data, queries pgvector for similar players, weighs upcoming fixtures, and streams a reasoned transfer recommendation back to the browser. Interactive, multi-step, branching, loops back on itself when the first plan doesn't survive contact with the tool results.
Why LangGraph here, when I rejected it for the day job
Six weeks before building this, I'd written an ADR at Curve choosing Pydantic AI over LangGraph for our LLM-Ops orchestration layer. Same engineer, opposite call. That isn't a contradiction — the framing from the earlier ADR is what makes the second decision: use-case shape, not capability count, picks the framework.
The Curve work is request-scoped agents that fan out for seconds.
asyncio.gather over Pydantic AI agents pays for itself
because the workload is parallel-fan-out, not stateful-loop. The Scout
Agent is the opposite: a single user request runs a conditional cycle
over a shared state, the planner needs to inspect tool results and
decide whether to keep going, and the iteration count needs to be
bounded explicitly. That's the shape LangGraph is built for, and forcing
it through plain asyncio would pay the abstraction cost without
collecting the abstraction benefit.
Both ADRs live in the FPL repo: FPL ADR-009. A sanitised write-up of the cross-call is on the writing page.
The shape
Four nodes: planner turns the user query into a sequence of
tool calls; tool_executor runs them (pgvector similarity
search, player history lookup, fixture difficulty, head-to-head
comparison); reflector reads the results and decides
whether the plan needs another pass; recommender emits the
final recommendation. The reflector → planner edge is conditional and
capped at three iterations — enough room for the agent to recover from
a bad first plan, not enough to run away with cost.
Embeddings are all-MiniLM-L6-v2 at 384 dimensions, stored in
Neon pgvector. Cheap, small, runs on the always-free Neon tier; the
quality lift from anything bigger would be invisible at this corpus
size (~700 players × a handful of facets each).
Why this needed an ADR — the OAC-on-POST landmine
The Scout Agent sits on a Lambda Function URL, fronted by CloudFront for
rate-limiting, geo, and to avoid leaking the Function URL directly. The
textbook pattern for that combination is CloudFront Origin Access
Control with AWS_IAM on the Function URL — CloudFront signs
each origin request, Lambda validates the SigV4, anything that goes
direct to the Function URL is rejected.
100% of POST requests returned 403 in production.
The cause: SigV4 requires the SHA256 of the request body in the canonical request. CloudFront cannot compute that ahead of time when the body is a stream that's still being sent — and on Server-Sent-Events-shaped requests, the body genuinely is a stream. Every AWS example for this pattern uses GET, where there's no body and the hash of empty is constant. The pattern is documented for GETs and silently broken for POSTs.
Secondary landmine: in October 2025 AWS started requiring both lambda:InvokeFunctionUrl and lambda:InvokeFunction
on the resource policy. Older guides only grant the first; new
deployments using those guides 403 with no useful error.
Fix: drop OAC + AWS_IAM, set the Function URL to
auth_type = NONE, and inject a shared-secret header on
every CloudFront origin request. The Lambda rejects anything missing or
mismatching the header. Function URL is effectively unreachable except
through CloudFront. Secret lives in SSM SecureString.
Full write-up: FPL ADR-010.
Streaming, briefly
The agent streams thinking tokens back to the browser over Server-Sent Events. Lambda Function URLs support response streaming up to 20MB of response body and a 15-minute response duration, which is generous; Lambda Web Adapter sits in front of the FastAPI app and converts the Lambda invocation model to a normal ASGI request. CloudFront's AllViewerExceptHostHeader origin-request policy forwards everything except the host header so the Function URL accepts the request; the host header gets stripped because Function URLs reject requests whose host doesn't match their own domain.
Safety
The agent's tools are fixed paths to Neon and S3; no user-controlled URLs, no shell, no eval. Parameters are validated through Pydantic before being assembled into the SQL or HTTP call. DynamoDB holds a per-request budget cap and rate limiter — cheap protection against runaway agent loops if the iteration cap fails to bind.