Building With LangGraph On Top Of Ujex (Reference Architecture)
LangGraph runs on whatever (your laptop / Cloud Run / fly.io). Ujex runs as Cloud Functions on a Firebase project. LangGraph state lives in a checkpointer (PostgresSaver in prod). Long-term memory lives in Ujex Recall. Email, ingress, budgets, mobile, audit are Ujex SDK calls. Total: ~300 LoC for a real agent loop.
This is the architecture I run for my own agents and the one I recommend in talks. It's small. It's been useful.
What LangGraph gives you
- State machine with explicit nodes + edges
- Checkpointer for thread-scoped state
- Streaming, interruption, replay
- Type-safe state transitions
What Ujex gives you
- Real email per agent (Postbox)
- Long-term markdown-first memory across sessions (Recall)
- Public webhook URL (Ingress)
- Spend cap (Governor)
- Phone approval (Mobile)
- Hash-chained audit log (Audit)
Where each runs
| Component | Runs where |
|---|---|
| LangGraph agent loop | Cloud Run / fly.io / your machine |
| Checkpointer (short-term state) | PostgresSaver — Cloud SQL or fly Postgres |
| Ujex Cloud Functions | GCP (your Firebase project) |
| Recall storage (markdown source of truth) | Cloud Storage bucket |
| Recall index | Firestore + Vertex AI embeddings |
| Mail server | Hetzner VM (or Mailgun / Brevo) |
| Ingress relay | Cloudflare Tunnel or Ujex's bore relay |
| Mobile push | FCM (Firebase) |
Code (Python)
from langgraph.graph import StateGraph
from langgraph.checkpoint.postgres import PostgresSaver
from ujex_recall import RecallStore
from ujex_postbox import Postbox
from ujex_mobile import Mobile
class AgentState(TypedDict):
task: str
plan: str | None
email_response: str | None
graph = StateGraph(AgentState)
postbox = Postbox(api_key=...)
mobile = Mobile(api_key=...)
recall = RecallStore(api_key=..., agent_id='task-agent')
def plan(state, store):
prior = store.search(('episodes',), query=state['task'], limit=3)
response = llm(f"Plan this task. Prior episodes: {prior}. Task: {state['task']}")
store.put(('episodes',), f'plan-{int(time.time())}', response)
return {'plan': response}
def execute(state):
if needs_approval(state['plan']):
mobile.ask(prompt='OK to execute?', detail=state['plan'], ttl_sec=300).wait()
# ... do the work
return {'email_response': 'Task done. PR: ...'}
def email_user(state):
postbox.send(
from_inbox='task-agent',
to=state['user_email'],
subject=f"Re: {state['task']}",
body=state['email_response'],
)
return {}
graph.add_node('plan', plan)
graph.add_node('execute', execute)
graph.add_node('email', email_user)
graph.set_entry_point('plan')
graph.add_edge('plan', 'execute')
graph.add_edge('execute', 'email')
checkpointer = PostgresSaver.from_conn_string('postgres://...')
agent = graph.compile(checkpointer=checkpointer, store=recall)
Deploy
# Ujex (one time per project)
cd ujex/functions && npm ci && firebase deploy --only functions
# LangGraph agent
fly deploy -c fly.toml # or: gcloud run deploy ...
Where the data lives
- Thread state: PostgresSaver in your Postgres (your control)
- Long-term memory:
.mdfiles in your bucket (your control) - Memory index: Firestore in your project (your control)
- Email: Firestore in your project; mail server on your VM (your control)
- Audit: Firestore in your project (your control)
No vendor owns the data. Ujex is open SDKs + opinionated control plane.
Tradeoffs
- Vs full vendor stack: more ops, more control, free at small scale
- Vs framework-with-batteries (Letta): mix-and-match — LangGraph for orchestration is powerful and Letta would replace some of that
- Vs no Ujex at all: you'd build the email + memory + audit yourself; Ujex is the "don't" version
FAQ
Can I use this without Postgres?
Yes — LangGraph also has SqliteSaver for dev. Use that for prototyping; switch to Postgres for prod.
Does this work with TS / JS?
Yes — LangGraph.js + @ujex/client. Same shape, JS idioms.
How much does this cost in GCP at small scale?
Free tier covers most early development. Firestore reads/writes scale linearly; Cloud Functions invocations free up to 2M/mo. Cloud Storage for memory: pennies.