Atlas
June 9, 2026 · View on GitHub
Atlas plans a trip one stop at a time. You describe where you're going; an open-source LLM proposes the next stop and renders it as a card it composes itself — name, why, when, and Approve / Skip buttons. Approve, and the stop pins to a live map and Atlas proposes the next one. When the itinerary is done, you get a completion card instead of more suggestions.
The point of the project: the agent doesn't just answer in text and leave you to build the UI — it generates the interface from a catalog of components you own, using Google's A2UI protocol, while a human approves every move. And it does this on an open-weights model served by Nebius Token Factory, not a frontier closed model.
Based on CopilotKit's official A2UI showcase, the A2UI PDF Analyst: https://github.com/CopilotKit/CopilotKit/tree/main/examples/showcases/a2ui-pdf-analyst This project reuses that example's A2UI plumbing (the catalog + renderer system, the surface bus, the mirror activity renderer, and the
HttpAgent→ Python bridge) and adapts it from "chat with a PDF" to "plan a trip", swaps the runtime model to an open model on Nebius, and adds the map + approval loop. Full credit to the CopilotKit team for the original. See Acknowledgements.
Table of contents
What it does
- You chat on the left; a map and the current proposal sit on the right.
- Ask first, plan when ready. Atlas holds a normal conversation — ask "how many days do I need in Thailand?" and it just answers. It only starts proposing stops once you have a destination and want to build the itinerary.
- Atlas proposes one real place per turn as an A2UI StopCard (category, name, one-line reason, suggested time, Approve / Skip).
- Add to trip drops a numbered pin at the place's real, geocoded location, draws the route line between stops, and Atlas proposes the next.
- Skip asks Atlas for a genuinely different place.
- Hover a pin for that stop's details, and a collapsible "Your plan" panel overlays the map with the full itinerary, grouped by day.
- You can steer at any time ("more local food", "skip the touristy stuff") and the next card reacts.
- When the trip is complete (or you say you're done), Atlas renders a completion card with a short recap and Add another stop / Start over — no more suggestions until you ask.
Architecture
Three layers, cleanly separated:
| Layer | What it is | Where |
|---|---|---|
| A2UI (Agent-to-UI) | Google's open protocol. The agent describes a UI surface as structured component operations (createSurface, updateComponents, updateDataModel) that the frontend renders against its own component catalog. | emitted by the Python agent |
| AG-UI | CopilotKit's transport. Carries A2UI operations to the browser and user actions back to the agent over HTTP/SSE. | @copilotkit/* |
| The agent | A LangGraph agent (create_agent) wrapped by the copilotkit Python SDK, served over AG-UI by FastAPI. Runs an open model on Nebius Token Factory via langchain-openai. | agent/ (Python) |
Browser (Next.js)
┌────────────────────────────────────────────────────────────┐
│ /trip page │
│ ┌─────────────┐ ┌──────────────────────────────────────┐│
│ │ CopilotChat │ │ TripWorkspace ││
│ │ (left) │ │ • Leaflet map (app-owned) ││
│ │ │ │ • current StopCard surface (A2UI) ││
│ └─────────────┘ └──────────────────────────────────────┘│
└───────────────┬─────────────────────────────▲──────────────┘
│ POST /api/copilotkit │ A2UI ops mirrored
▼ (CopilotRuntime + HttpAgent)│ to the canvas
┌────────────────────────────────────────────────┐
│ Next.js API route (bridge, runtime/v2) │
│ a2ui: { injectA2UITool: false } │
└───────────────┬──────────────────────────────────┘
│ AG-UI over HTTP/SSE
▼
┌────────────────────────────────────────────────┐
│ Python FastAPI :8123 ── /trip │
│ LangGraph create_agent + CopilotKit middleware│
│ + OneProposalPerTurn (ends turn after 1 card) │
│ tools: propose_stop, finish_trip │
│ coords geocoded via OpenStreetMap / Nominatim │
│ model: Nebius open LLM (langchain-openai) │
│ emits A2UI ops via copilotkit.a2ui helpers │
└────────────────────────────────────────────────┘
Why a Python agent? This is the A2UI pattern from the upstream example: the
agent emits A2UI operations as tool results, and CopilotKit's A2UI middleware
turns any tool result containing a2ui_operations into rendered surfaces. The
Next.js side is a thin bridge — CopilotRuntime wrapping an HttpAgent that proxies
to the Python endpoint. There is no TypeScript agent.
Why the map isn't A2UI. A2UI shines for cards, forms, tables, and charts the agent composes. The map is a different beast — it's an app-owned Leaflet canvas that consumes approvals. The StopCard is A2UI; the map is plain React fed by approvals.
How a turn works (the full loop)
Conversation vs planning. Not every turn renders a card. If you ask a question or haven't settled on a destination, Atlas just replies in chat. Once you're ready to build, it switches to the loop below.
- You ask ("Plan me 3 days in Lisbon, food-heavy, easy pace"). The message goes
through the bridge to the Python
trip_agent. - The agent calls
propose_stop(name, region, lat, lng, category, note, time)— a typed tool. The real coordinates are resolved by geocoding the place within its region (OpenStreetMap/Nominatim), so the pin lands correctly; the model's lat/lng is only a fallback. It builds a StopCard component tree (from the shared catalog) and returns A2UI ops:create_surface+update_components. The Approve button inlines the full stop into its action context. - The surface streams to the browser. The agent's tool result carries
a2ui_operations; the A2UI middleware emits an activity message;MirrorRendererforwards the ops onto thesurface-bus;TripWorkspace's canvas renders the StopCard. A small "surface → rendered in the canvas" pill is left in chat. - You click Add to trip. The catalog's Button renderer dispatches its
action.event;A2UIProvider'sonActionreceives it asuserAction = { name: "approve_stop", surfaceId, context: { …the stop } }. TripWorkspace.onActiondoes two things:- reads
userAction.contextand pushes the stop into thetripStore→ the map pins it and extends the route; - re-runs the agent with
forwardedProps: { a2uiAction }, so CopilotKit's A2UI middleware injects alog_a2ui_eventtool result on the next run. It also immediately "consumes" the current card (replaces it with an "Atlas is thinking…" state) so it can't linger or be double-clicked.
- reads
- The agent sees the approval (
log_a2ui_event), acknowledges in one line, and callspropose_stopfor the next place — back to step 2. - When the trip is complete, the agent calls
finish_trip(summary)instead, which renders the completion card (Add another stop / Start over). Those buttons fireplan_more/restart_tripevents through the sameonActionpath;restart_tripclears the map.
skip_stop works like approve_stop minus the pin: the agent proposes a different
place.
The pause is enforced, not requested. A small OneProposalPerTurn middleware ends
the agent's run the moment one propose_stop / finish_trip fires, so the loop always
waits for your click — even with a strong, eager model that would otherwise plan the
whole trip in a single turn.
Tech stack
| Part | Stack |
|---|---|
| Frontend | Next.js 16 · React 19 · Tailwind v4 · TypeScript · @copilotkit/react-core/v2 · @copilotkit/a2ui-renderer · react-leaflet v5 + Leaflet · Recharts (catalog charts) |
| Bridge | @copilotkit/runtime/v2 · @ag-ui/client (HttpAgent) · @ag-ui/core |
| Backend | Python 3.12 · FastAPI · ag-ui-langgraph · copilotkit (Python SDK) · LangChain + LangGraph · langchain-openai · OpenStreetMap / Nominatim geocoding |
| Model | Open LLM via Nebius Token Factory (OpenAI-compatible) |
Prerequisites
- Node.js 20+ and pnpm (npm works too)
- Python 3.12
- uv for the Python agent (
pip install uvis fine) - A Nebius Token Factory API key — https://tokenfactory.nebius.com/ → Settings → API keys
Setup & run
# 1. install (also runs `uv sync` for the agent via postinstall)
pnpm install
# 2. add your key
cp agent/.env.example agent/.env
# edit agent/.env: NEBIUS_API_KEY=...
# 3. run web (:3000) + agent (:8123) together
pnpm dev
On Windows PowerShell, use copy agent\.env.example agent\.env. npm install && npm run dev works identically.
Open http://localhost:3000 — it redirects to /trip. Type:
Plan me 3 days in Lisbon, food-heavy, easy pace.
Quick health check: http://localhost:8123/ returns
{"ok": true, "agents": {"trip_agent": "/trip/"}}.
Configuration: the model
The model is configured once, on the Python side, in agent/src/llm.py. Nebius is
OpenAI-compatible, so it's a ChatOpenAI with the base URL and key overridden:
ChatOpenAI(
model=os.environ.get("NEBIUS_MODEL", "Qwen/Qwen2.5-72B-Instruct"),
base_url=os.environ.get("NEBIUS_BASE_URL", "https://api.tokenfactory.nebius.com/v1"),
api_key=os.environ["NEBIUS_API_KEY"],
temperature=0,
)
Override the model without touching code via agent/.env:
NEBIUS_API_KEY=...
NEBIUS_MODEL=Qwen/Qwen2.5-72B-Instruct
# NEBIUS_BASE_URL=https://api.tokenfactory.nebius.com/v1
Model choice matters. The whole loop depends on the model reliably calling
tools (propose_stop, finish_trip). Pick a strong instruct model with solid
function-calling — e.g. Qwen/Qwen2.5-72B-Instruct, meta-llama/Llama-3.3-70B-Instruct,
deepseek-ai/DeepSeek-V3, or a large Nemotron. Confirm exact IDs in the Token Factory
playground (the catalog changes). A -fast variant, where offered, keeps the
propose→approve loop snappy. Small/omni models tend to be slow and unreliable at
tool-calling — the symptom is the agent replying in prose instead of rendering a card.
Coordinates aren't the model's job: it supplies the place name and region, and the
agent geocodes the real location (see Customizing to swap in a keyed
geocoder for venue-precise pins).
Project structure
agent/ # Python AG-UI agent
├── main.py # FastAPI app; registers /trip
├── pyproject.toml # uv-managed deps
├── .env.example # NEBIUS_API_KEY (+ optional overrides)
└── src/
├── trip_agent.py # agent: tools, prompt, geocoding, HITL middleware
├── llm.py # Nebius model factory (get_model)
├── catalog.py # CATALOG_ID shared with the frontend
└── a2ui/schemas/stop_card.json # reference StopCard tree (for tweaking)
src/ # Next.js app
├── app/
│ ├── layout.tsx # root layout + Providers
│ ├── page.tsx # "/" → redirects to /trip
│ ├── trip/page.tsx # the app: chat (left) + workspace (right)
│ ├── globals.css
│ └── api/copilotkit/route.ts # bridge: CopilotRuntime + HttpAgent → :8123/trip
├── components/
│ ├── Providers.tsx # <CopilotKit> + trip mirror renderer
│ ├── TripWorkspace.tsx # map + current surface + onAction (the loop glue)
│ ├── TripMap.tsx / TripMapInner.tsx # Leaflet map (pins + route + hover tooltips)
│ ├── ItineraryPanel.tsx # "Your plan" overlay — itinerary grouped by day
│ └── Split.tsx # draggable two-pane layout
└── a2ui/
├── catalog/
│ ├── definitions.ts # Zod prop schemas + agent-facing descriptions
│ ├── renderers.tsx # the React renderers (Card, Button, charts, …)
│ └── index.ts # createCatalog() → catalog + catalogSchema
├── MirrorRenderer.tsx # forwards A2UI ops from chat → surface bus
├── surface-bus.ts # tiny per-agent event bus (chat → canvas)
├── theme.css # brand tokens, scoped to .a2ui-surface
└── trip-store.ts # approved stops; bridges approve events → map
The key files, in one line each
trip_agent.py—propose_stopbuilds a StopCard from catalog components with the stop (geocoded to its real location) inlined into the Approve button's context;finish_tripbuilds the completion card. TheOneProposalPerTurnmiddleware ends the turn after one proposal (the human-in-the-loop guarantee); the system prompt sets the conversation-vs-planning behaviour and forbids re-proposing an added place.route.ts— registers thetrip_agentHttpAgent(aliased todefault); setsa2ui: { injectA2UITool: false }because the Python agent emits the ops itself.TripWorkspace.tsx— the glue. Renders the map over the current surface, and itsonActionpins approved stops, clears the consumed card, and re-runs the agent.trip-store.ts— a minimal external store of approved stops the map subscribes to.catalog/— the design system the agent draws from;CATALOG_IDis shared withagent/src/catalog.pysocreateSurfaceresolves to these renderers.
The A2UI catalog
A2UI never ships layout code or arbitrary markup to the browser — the agent only
references component types from a catalog you define. Each component is a Zod prop
schema (definitions.ts) paired with a React renderer (renderers.tsx), registered
via createCatalog() (index.ts). This is why an open model can drive the UI safely:
it composes from primitives you already trust, and the look stays entirely yours.
The shared catalog includes layout (Stack, Row, Grid, Section, Card,
Divider), content (Heading, Text, Overline, Badge, Callout, BulletList),
data viz (StatCard, BarChart, HorizontalBarChart, LineChart, DonutChart,
ScatterChart, DataTable), and interactive (Button, ChoiceChips). The StopCard
is composed from Card + Stack + Overline + Heading + Text + Badge + Row
Button— no custom renderers were needed.
Customizing
- Change the card design — edit
build_stop_card()intrip_agent.py(reorder / add catalog components).agent/src/a2ui/schemas/stop_card.jsonis a reference example of the resulting tree. - Tune Atlas's behaviour — edit
SYSTEM_PROMPTintrip_agent.py(pacing, how many stops before finishing, tone). - Swap the model — set
NEBIUS_MODELinagent/.env. No code change. - Restyle —
src/a2ui/theme.css(catalog surfaces) andsrc/app/globals.css(app shell). The map's look is the CARTO dark tiles + pin styles inTripMapInner.tsx. - Coordinate accuracy —
geocode()intrip_agent.pyresolves pins via OpenStreetMap/Nominatim, bounded to the stop's region. Swap in a keyed geocoder (Google / Mapbox) there for venue-precise pins. - Conversation vs planning — the two-mode behaviour (chat freely, propose only when
ready) lives in
SYSTEM_PROMPT; the one-proposal pause lives in theOneProposalPerTurnmiddleware — both intrip_agent.py. - Add a new component the agent can use — add a Zod definition + a renderer to the catalog, then reference it from a tool's component tree.
Acknowledgements
This project is adapted from CopilotKit's official A2UI PDF Analyst showcase:
https://github.com/CopilotKit/CopilotKit/tree/main/examples/showcases/a2ui-pdf-analyst
The shared A2UI infrastructure — the catalog + renderer system, surface-bus.ts,
MirrorRenderer.tsx, the HttpAgent → Python bridge, the Split layout, and the
catalog component renderers — derives from that example. Atlas adapts it to a
trip-planning domain, replaces the PDF agents with a single trip agent, swaps the
runtime model to an open model on Nebius Token Factory, and adds the Leaflet map
and per-stop approval loop.
Built with CopilotKit, the A2UI protocol, AG-UI, and Nebius Token Factory.
License
This repository's source code is available under the MIT License.
Map data: tiles © CARTO, map data © OpenStreetMap contributors. (Hidden on the map via attributionControl={false}; set it to true to show the credit on screen instead.)