<This part is not AI generated>
I want to explain my process before the AI part begins. I studied agentic behaviour here, with the important concepts they require by their nature. Since it's a distributed system with an async nature, they present unique challenges. Idempotency, at-least-once delivery, and handling failures are a few of them.
So the architecture relies on state machines, LLM output validation, and proper orchestration. This project has the fundamental concepts applied as requested by the case study.
I also used GCP Functions Framework since it's a good fit for this kind of system.
I used Claude Code to make the process shorter and aligned to best practices.
I debugged each step as I was simulating my standard dev process. Evolved the source code through incremental steps.
Main usages:
- Enhance my notes to become guidelines
- Generate boilerplate code, mocks, tests
- Find edge cases, code review, optimization
- Full pipeline via curl: message in →
completedwith result - Duplicate request returns
deduplicated: true, no reprocessing - Validation gate rejects low-confidence input with reason
- All 4 tool types complete successfully
- Docker build and run
- Cloud Run deployment and live endpoint testing
- 24 tests passing across 3 test files
</This part is not AI generated>
Event-driven AI agent backend with decoupled services, state-machine idempotency, and a validation gate between reasoning and execution.
HTTP Request → API Service → [Pub/Sub] → Reasoning → [Validation Gate] → [Pub/Sub] → Executor
↕ ↕ ↕
StateStore StateStore StateStore
Three services, one process (for now):
- API — Zod-validated ingress, deduplication via messageId, returns 202 Accepted
- Reasoning — Mock LLM processing, Zod-validated output before emitting action events
- Executor — Tool execution with state-based idempotency
Services communicate only through Pub/Sub events and share a StateStore. In production, each becomes its own Cloud Run service backed by real GCP Pub/Sub and Firestore — the consumer code stays identical.
received → reasoning → reasoning_complete → executing → completed
↘ failed ↘ failed
Transitions are enforced by VALID_TRANSITIONS. The updateStatus(id, expectedCurrent, next) method acts as both a state guard and the idempotency mechanism — if the state already moved, the transition returns false and the duplicate is skipped.
Three levels, each preventing duplicate side effects:
| Level | Where | Mechanism |
|---|---|---|
| API | POST /messages |
messageId used as conversationId. Atomic createConversation returns false if already exists. |
| Event processing | Reasoning + Executor consumers | State transition guard: updateStatus(id, expected, next) only succeeds if conversation is in the expected state. |
| Side effects | Tool execution | Tool only executes if state transitions from reasoning_complete → executing. Second delivery finds state moved on. |
LLM output is treated as unknown and must pass through Zod validation before any action event is emitted. If validation fails, the conversation moves to failed with a reason — no action event is ever emitted.
| Scenario | Behaviour |
|---|---|
| Duplicate API request | Same messageId → returns existing state, no reprocessing |
| Duplicate Pub/Sub delivery | State transition guard rejects, message acked |
| Reasoning throws | Caught, conversation marked failed, message acked |
| Validation rejects LLM output | Conversation marked failed with reason, no action event emitted |
| Tool execution throws | Caught, conversation marked failed, message acked |
| Publish fails after state transition | Conversation stays in current state — recoverable by reconciliation sweep |
Single-pass vs. agent loop: The current system processes one reasoning → execution cycle per message. In a production agent, the conversation record would evolve into an accumulated context carrying message history and intermediate tool results, enabling the LLM to reason over multiple turns. The event-driven architecture supports this naturally — the executor would publish an observation event back to the reasoning consumer, creating a loop. The state machine would gain a reasoning_complete → reasoning transition, and the LLM would decide when to emit a final answer vs. request another tool call.
npm install# Build
npm run build
# Local (needs build first)
npm start
# Dev (builds and starts local server)
npm run dev
# Docker
docker build -t loops-ai-agent .
docker run --rm -p 8080:8080 loops-ai-agentgcloud run deploy loops-agent --source . --region europe-west1 --allow-unauthenticatednpm test # 24 tests across 3 test files
npm run typecheck # tsc --noEmitSend a message:
curl -X POST http://localhost:8080/messages \
-H "Content-Type: application/json" \
-d '{"messageId": "msg-1", "content": "show me product details"}'
# → 202 { conversationId: "msg-1" }Check status:
curl http://localhost:8080/conversations/msg-1