An agentic email assistant built on LangChain 1.0 create_agent(). A supervisor
agent orchestrates specialist worker agents to triage an inbox, summarize
threads, draft contextual replies, and schedule follow-ups — with human
approval on every outgoing action and memory that improves its behavior over time.
Integrations (Mail, Calendar) are mocked behind swappable adapter interfaces, so the
whole thing runs end-to-end without real accounts. The demo is a notebook
(notebooks/demo.ipynb).
- Triage — read, classify (a structured
{intent, priority}via theclassifytool), label, and route messages. - Summarize — condense a thread to TL;DR + the key points.
- Draft — compose a contextual reply (returns the text; does not send).
- Schedule — work out follow-up reminders / calendar events.
- Human-in-the-loop — approve / edit / reject before anything is saved or scheduled, and a structural gate that hides a commit tool until its work was actually done.
- Learns over time — preferences, contacts, and standing facts persist across conversations and are recalled into later turns (including preferences stated while editing an action).
A supervisor create_agent whose tools are the four worker agents
(agents-as-tools) plus its own gated outgoing-action tools. One process() / resume()
per turn; the supervisor's loop composes the workers. Workers are read/compose-only —
only the supervisor commits, and only behind approval.
adapters/ — Mail & Calendar ABCs + in-memory mocks
tools/ — @tool closures over a client + registry (name -> tool)
memory/ — SemanticMemoryStore (long-term) + MemoryRecallMiddleware (read path)
factory.py — build workers, wrap as tools, build the supervisor (+ middleware)
config.py — YAML loader -> typed AppConfig (env interpolation)
llm_factory.py — named model specs -> instances (memoized)
prompts.py — structured-output schemas + prompts (decide / consolidate)
logging_setup.py — setup_logging() + redact()
assistant.py — EmailAssistant: from_config_dir / process / decide / resume
See docs/architecture.md for the component breakdown, the execution flow, and diagrams of the HITL loop and the memory read/write paths.
uv sync --extra dev # create .venv and install deps + dev tools
cp .env.example .env # then fill in your Azure OpenAI vars.env needs Azure OpenAI credentials and two deployments — a chat model and an
embeddings model (used for long-term memory). The deployment names live in
config/llm_factory.yaml.
The entry point is the notebook — it tours config, adapters/tools, assembly, the
end-to-end process() flow, human-in-the-loop approval, and long-term memory:
uv run jupyter lab notebooks/demo.ipynbOr drive it directly:
from email_assistant import EmailAssistant
from email_assistant.adapters.mail import MockMailClient
from email_assistant.adapters.calendar import MockCalendarClient
assistant = EmailAssistant.from_config_dir(
"config",
mail_client=MockMailClient.from_json("data/seed_inbox.json"),
calendar_client=MockCalendarClient(),
)
turn = await assistant.process("Summarize the Q3 budget thread (t1).", thread_id="demo")
print(turn.reply)
# Human-in-the-loop: process -> decide -> resume
turn = await assistant.process("Draft a reply to Alice and save it.", thread_id="t2")
if turn.status == "interrupted":
decisions = await assistant.decide("looks good — sign it 'Kind Regards, Anastasis'", turn.pending)
turn = await assistant.resume("t2", decisions, note="sign it 'Kind Regards, Anastasis'")process() / resume() return a TurnResult — either completed (with reply) or
interrupted (with pending actions awaiting approval).
All config is YAML under config/, loaded into one typed AppConfig
(config.load_config). Model settings live once in llm_factory.yaml and agents
reference a config by key; secrets use ${VAR} / ${VAR:-default} / $VAR
interpolation so nothing sensitive is committed.
llm_factory.yaml— named model specs (class_path+config), incl. the embeddings model.supervisor_agent.yaml— supervisor prompt,llmkey, owned outgoingtools, thehitlsubset gated for approval, andgates(outgoing tool → the worker whose output it needs, so a commit tool stays hidden until that work was done this turn).worker_agents.yaml— per worker:title(→ tool name),description(routing signal),prompt,llmkey,tools.memory.yaml— declarative backing store +embeddingskey,user_id,top_k.classifier.yaml— thellmkey for the message classifier (intent/priority taxonomy is single-sourced inclassifier.py).
- Short-term — an
InMemorySavercheckpointer on the supervisor, keyed bythread_id: a conversation continues across calls and HITL pauses are resumable. - Long-term —
SemanticMemoryStore, a vector-indexed store namespaced(user_id, category)overpreferences/contacts/facts.- Write — extraction is folded into the supervisor turn: it returns structured
{response, memory}, and each candidate is consolidated (search neighbours →add/update-supersede /noop) so the store stays clean rather than append-only. Runs automatically when a turn completes — no separate reflection call. - Read —
MemoryRecallMiddlewaresemantically searches the store with the latest user message and injects the top-k hits into the prompt (per-call, not persisted to history). This is what lets a brand-new thread apply a preference stated once before. - Learning from edits — a preference stated while editing an action (e.g. a
sign-off during approval) reaches only
decide().resume(note=reply)replays it as its own no-action turn so it's recorded and learned, not lost.
- Write — extraction is folded into the supervisor turn: it returns structured
- Logging — stdlib logging configured once via
setup_logging(); secret-looking values are masked byredact(). Every step is logged: model instantiation, worker/supervisor/registry/memory build, assembly, per-turnprocess start/donewiththread_id, worker invocations, tool calls, and memory recall/write/consolidate. See docs/sample-logs.txt for a full captured run (regenerate offline withuv run python scripts/sample_run.py). - Tracing — LangSmith works via the standard
LANGSMITH_*env vars; the key runs are namedagent:Supervisor,llm:Decision, andllm:Memoryfor readable traces.
uv run pytest tests/ -qTests are provider-free (stub models + a deterministic fake embedding) and cover config
loading, adapters/tools, the factory, memory (store / consolidation / recall / HITL-note
replay), and assembly + process().
A CLI (the notebook is the entry point); observability metrics + error tracking; memory housekeeping (metadata, per-category caps, TTL, background compaction); real Gmail/Calendar adapters (the mocks document the per-resource concurrency a real adapter would need).