使用说明
dtcc-agent
LLM agent interface for the DTCC Platform. Exposes urban digital twin simulations as MCP tools, enabling Claude and other LLM agents to discover datasets, inspect urban context, run environmental simulations, and interpret results through natural language.
Architecture
LLM (Claude) ←→ MCP (stdio, or streamable HTTP + X-DTCC-Session) ←→ dtcc-agent
│ per Session: object store, Runs, artifact folder
├── geocode (pyproj + Nominatim)
├── get_buildings (dtcc_core.datasets)
├── run_simulation (dtcc-sim remote or in-process)
├── compare_scenarios (composes the above)
├── list_operations ─┐
├── describe_operation │ dynamic dispatch
├── run_operation │ (133 operations)
├── list_objects │ with object store
├── inspect_object │
├── delete_object │
├── get_field_names │
├── export_object │
├── object_to_text │
├── spatial_query ─┘
├── render_object (matplotlib PNG, per-Session file)
├── memory budget (2 GiB shared by all Sessions, LRU)
└── disk_cache (the two downloads: containment, TTL)
dtcc-agent runs simulations in one of three ways, chosen by configuration, with no fallback between them:
- Engine mode (#109), with
DTCC_ENGINE_URLset: each simulation is a DTCC Engine job (dtcc-twinapps/engine). The agent submits it, polls it, and downloads the result as a.dtccpkgthat Core reads, all over HTTP: nothing is shared on disk, so the agent and the Engine can run on different machines. dtcc-sim settings are ignored while it is set. Buildings, terrain and the other operations still run in the agent (#110 measures moving them). - Mini-service mode: Lurkie and the MCP server run in a lightweight
Python container. Simulation tools call the
dtcc-simmini-service through the samedtcc-coreremote dataset protocol used by Atlas. Jobs are submitted and polled over HTTP, but the result file is read from a folder both services mount (SHARED_RESULTS_DIR). The agent asks for Core's nativedtccformat and decodes it for field statistics, so it needs a dtcc-sim build with dtcc-sim#8; an older build answers without temperature values. - Direct mode: the MCP server imports
dtcc_coreanddtcc_simdirectly in-process. This requires the full scientific environment.
Prerequisites
For the Docker mini-service, you need:
- Docker
- A running
dtcc-simmini-service - Amazon Bedrock access to Claude, usually a Bedrock API key in
AWS_BEARER_TOKEN_BEDROCK
For direct in-process usage, dtcc-agent runs in the same environment as dtcc-sim. You need:
- FEniCSx (dolfinx) via conda
- dtcc-core and dtcc (pip)
- dtcc-sim (pip)
- dtcc-tetgen-wrapper (from source)
See the dtcc-sim README for environment setup instructions.
Installation
pip install -e .
For the web chatbot extras:
pip install -e ".[chatbot]"
Usage
With Claude Desktop / Claude Code
Add to your MCP configuration (.mcp.json or Claude Desktop settings):
{
"mcpServers": {
"dtcc-agent": {
"command": "python",
"args": ["-m", "dtcc_agent"]
}
}
}
Standalone
python -m dtcc_agent
This serves over stdio. To serve over streamable-http instead:
DTCC_MCP_TRANSPORT=http DTCC_MCP_HOST=127.0.0.1 DTCC_MCP_PORT=8051 python -m dtcc_agent
Over HTTP every tool call must carry an X-DTCC-Session header; a call without one is
refused. Objects and runs belong to that Session and are never visible from another
(ADR-0004). The transport is stateless: the Session travels in the header, not in the MCP
connection, so a client may open a new connection per request. A Session with no tool call
for 60 minutes is dropped along with its objects and runs, and the next call under its id
starts a fresh one. At most 8 Sessions are live at once. Past that, the least recently used
idle Session is dropped; a Session with a tool call in flight is never dropped. The chatbot
ends its own sessions after 60 minutes without a message, deleting their files, and the page
starts a new chat. To point the chatbot at the
HTTP server, set DTCC_MCP_URL=http://127.0.0.1:8051/mcp; it then sends its own session id
in that header. Without DTCC_MCP_URL the chatbot falls back to spawning the server over stdio.
The operation catalogue is built once per process. The HTTP server builds it at startup,
before it accepts a request, and prints dtcc-agent: catalogue built: N operations to
stderr; if any part of the catalogue from the pinned dtcc-core fails to register, it exits
naming that part instead of serving a smaller catalogue. Over stdio the catalogue is built on
the first call that needs it, since the chatbot starts a stdio server per message and most
never read it; a broken part then fails that call, naming the part. Datasets from a dtcc-sim
service (DTCC_SIM_SERVICE_URL) are not part of the build. When the catalogue is built, a
background thread asks the service, and again every 30 seconds while it is down (the agent
warns once; dtcc-core's own discovery warning still repeats each time); the catalogue picks its datasets up on the next read after it answers. Until
then, asking for one of them says dtcc-sim hasn't answered yet. No request ever waits on
dtcc-sim, and the two services can start in either order. Once dtcc-sim has answered it is
not asked again, so restart the agent after redeploying dtcc-sim: until then a new
simulation is missing and a removed one is still offered (#46).
At most DTCC_MCP_WORKERS tool calls (default 4) run at once across all Sessions, and at
most half of them (at least 1) from one Session; the rest wait their turn. Each Core operation
can copy a large input, so size it to the host's memory. Over stdio the one client may use all
of them. Two calls that would download the same dataset for the same bounds never download
at once, including get_buildings and datasets.buildings for one area: the later one waits,
holding one of its Session's share but none of the process-wide workers, then usually reads
the cache. The share is per X-DTCC-Session id, which is trusted as sent until central
auth lands (U11, #15): a client that invents ids gets a share per id.
Docker Mini-Service
For what a deployment needs (ports, secrets, settings, storage, memory, sticky routing, health checks, outbound network), see docs/deployment.md.
Start dtcc-sim and the agent service, in either order. After redeploying dtcc-sim
on its own, restart the agent so it picks up the new list of simulations:
cd ../dtcc-sim
docker compose up -d
cd ../dtcc-agent
mkdir -p data/agent
export DTCC_REMOTE_SERVICES=http://host.docker.internal:8001
export AWS_BEARER_TOKEN_BEDROCK=... # a Bedrock API key
export DTCC_MCP_SECRET="$(openssl rand -hex 32)"
export DTCC_AGENT_ACCESS_CODE="$(openssl rand -base64 18)" # give this to testers
docker compose up --build
Compose refuses to start without DTCC_MCP_SECRET, DTCC_AGENT_ACCESS_CODE and AWS_BEARER_TOKEN_BEDROCK, naming
the one that is missing.
Simulations on the DTCC Engine (#109)
| Variable | Default | Effect |
|---|---|---|
DTCC_ENGINE_URL |
unset | The Engine's base URL. Set: simulations run as Engine jobs, and DTCC_REMOTE_SERVICES, DTCC_SIM_SERVICE_URL and the shared results folder are not used. |
DTCC_ENGINE_TOKEN |
unset | The Engine's API token, sent as a bearer token. Required with the URL: the tool server does not start without it. |
DTCC_ENGINE_TIMEOUT |
900 |
Seconds a simulation may take before the tool reports a timeout, naming the job. |
A local Engine runs from dtcc-twin with docker compose --profile engine up -d, on
127.0.0.1:8000 with token local-dev-engine-token; from the agent's container that is
http://host.docker.internal:8000.
Admission (T14)
| Variable | Read by | Effect |
|---|---|---|
DTCC_AGENT_ACCESS_CODE |
chatbot | Opening a new chat needs this code, at least 16 characters. The page asks for it once per tab. A missing or wrong code closes the websocket with 4401 and creates no session. A live session resumes on its id alone. Unset: admission is off and the chatbot logs a WARNING at startup. Too short: the chatbot does not start. |
DTCC_MCP_SECRET |
both | Every HTTP request to the MCP server, initialize included, must carry Authorization: Bearer <secret>, or it gets a bare 401. The chatbot sends it. Unset on a non-loopback DTCC_MCP_HOST: the server does not start. |
Each session also carries a subject, sent to the MCP server as X-DTCC-Subject (stdio:
DTCC_AGENT_SUBJECT). It is "anonymous" until central auth exists, and nothing is authorised
by it yet.
The model (Bedrock)
The agent answers through Amazon Bedrock only (#29). Claude has no on-demand model IDs in
eu-north-1, so models are named by their eu.* cross-region inference profiles.
| Variable | Default | Effect |
|---|---|---|
AWS_BEARER_TOKEN_BEDROCK |
none | A Bedrock API key. Standard AWS credentials (AWS_ACCESS_KEY_ID, AWS_PROFILE) also work outside Compose. With none set, the chatbot does not start. |
AWS_REGION |
eu-north-1 |
The Bedrock Region. |
DTCC_AGENT_MODEL |
eu.anthropic.claude-sonnet-5-5 |
The Bedrock model ID the agent answers with. Changing models is this variable alone. |
DTCC_AGENT_BEDROCK_PROBE |
1 |
At startup the chatbot sends Bedrock one 1-token request in the background, and logs an error if the key is rejected (401/403), which the presence check above can't see. 0 turns it off. Either way the server starts. |
The agent loop runs in the chatbot process on pydantic-ai (#86). The Claude Agent SDK, M3's
rollback, was removed in M4; a leftover DTCC_AGENT_RUNTIME set to anything but pydantic-ai
stops the chatbot at start.
Every 4.x model (Sonnet 4.5, Sonnet 4.6, Haiku 4.5) is refused on an account until Anthropic's use-case form is filed in the Bedrock console. Sonnet 5, Sonnet 5.5, Opus 5 and Opus 5.5 are not (checked 2026-10-06, #88). Check access with one call before a long run:
curl -s -o /dev/null -w '%{http_code}\n' \
"https://bedrock-runtime.eu-north-1.amazonaws.com/model/eu.anthropic.claude-sonnet-5-5/converse" \
-H "Authorization: Bearer $AWS_BEARER_TOKEN_BEDROCK" -H "Content-Type: application/json" \
-d '{"messages":[{"role":"user","content":[{"text":"ok"}]}],"inferenceConfig":{"maxTokens":5}}'
200 means the key and model access are good.
Compose runs two services from one image (T13):
dtcc-agent-mcp, the MCP server over streamable HTTP. It binds127.0.0.1:8051and that port is never published (U11), so nothing outside the pair can reach it, and every call must also carryDTCC_MCP_SECRET(T14). It owns the network namespace, so the chatbot's port 8050 is published here.dtcc-agent, the chatbot. It shares that namespace (network_mode: service:dtcc-agent-mcp) and reaches the server athttp://127.0.0.1:8051/mcp, sending its session id inX-DTCC-Sessionand the secret. It starts once the MCP server is healthy, which is after the catalogue is built.
Both mount the same /data. The MCP server writes each Session's files to /data/artifacts
and the chatbot serves them. Only the MCP server mounts /shared/results.
The chatbot listens on http://localhost:8050 and exposes a health endpoint at http://localhost:8050/health.
The Docker layout mirrors dtcc-sim:
Dockerfiledocker-compose.ymlbuild_docker.sh
Available Tools
Simulation tools (hardcoded)
| Tool | Description |
|---|---|
geocode |
Convert place names to EPSG:3006 bounding boxes |
get_buildings |
Inspect buildings in a bounding box |
list_simulations |
Discover available simulation types |
get_simulation_schema |
Get parameter schema for a simulation |
run_simulation |
Run a simulation and return summary statistics |
compare_scenarios |
Run two simulations and compare results |
list_past_runs |
List recent simulation runs |
get_run_summary |
Re-inspect a past simulation result |
Dynamic dispatch tools (133 operations)
| Tool | Description |
|---|---|
list_operations |
Browse the full catalog of dtcc-core operations |
describe_operation |
Get parameter schema for any operation |
run_operation |
Execute any operation and store the result |
list_objects |
List objects in the in-memory store |
inspect_object |
Get detailed summary of a stored object |
delete_object |
Delete a stored object and free memory |
get_field_names |
Discover fields/data attached to a stored object |
export_object |
Export an object to a file the user downloads from the chat (CSV, OBJ, PLY, STL, VTK, glTF; buildings as GeoJSON, GeoPackage or City JSON) |
object_to_text |
Get a rich markdown representation of a stored object |
spatial_query |
Spatial filtering, nearest-station lookup, height-based queries |
Visualization tools
| Tool | Description |
|---|---|
render_object |
Render a stored object as a PNG shown in the chat (matplotlib; plan view for footprints and lines, 3D for meshes and point clouds) |
The dynamic dispatch tools expose the whole catalogue of the pinned dtcc-core (a78df16, 133
operations; CI prints the count on every run):
| Category | Count | Examples |
|---|---|---|
builder |
82 | terrain rasters, surface meshes, building heights, tree detection, pointcloud filters |
datasets |
24 | point_cloud, buildings, terrain and city meshes, trees, roads, weather, transit, air quality |
io |
21 | load/save pointcloud, mesh, raster, city. The ones taking a file path are refused from chat (U1); use export_object instead |
reproject |
6 | reproject pointcloud, mesh, surface between CRS |
dtcc-sim's datasets join this list once the service answers.
How It Works
Object store and pipelines
Results from run_operation are stored in memory with short 8-character
hex IDs. You pass these IDs as parameters to subsequent operations,
building multi-step pipelines:
run_operation("datasets.point_cloud", {"bounds": [...]})
→ object_ref: "obj_a1b2c3d4" (PointCloud stored in memory)
run_operation("builder.build_terrain_raster", {"pc": "obj_a1b2c3d4", "cell_size": 2.0})
→ object_ref: "obj_e5f6g7h8" (Raster stored, built from the point cloud)
Parameters marked is_object_ref: true in describe_operation output
accept these Object references (obj_…). The dispatcher resolves them from the store
automatically, and refuses a Run reference (run_…) passed in their place.
Each Session has its own store, and every store draws on one 2 GiB process budget
(T11, #23). When the stores together pass it, the least recently used object in any
Session is evicted, so memory an idle Session holds goes to the ones in use. Over HTTP
one Session may hold at most 1 GiB; over stdio the one Session may use all of it.
Sizes are counted from what an object really holds: arrays, dict and list contents, and
Core geometry (U4, #13). A single result bigger than the Session's limit is not kept: the
call still returns its summary, with object_ref: null and a not_stored note. A Session
remembers its last 100 simulation Runs. The budget covers stored results; memory while an
operation runs is bounded by DTCC_MCP_WORKERS and by the download cap below.
A download built from LiDAR (point clouds, buildings, city and terrain meshes, trees) may
cover at most DTCC_AGENT_MAX_AREA_KM2 (default 10 km²); a larger box is refused before
anything is fetched, asking for a smaller area (#79). Memory grows faster than area: in the
Docker image a point cloud peaked near 1.7 GB at 9 km² and 6 GB at 49 km², and buildings
at 5.3 GB at 25 km². Sensor, transit and vector downloads (weather, roads, footprints, DeSO
and the like) are not capped. In Compose the MCP server also has a memory limit
(DTCC_MCP_MEM_LIMIT, default 5g) and both services restart after a crash.
What gets returned
run_operation never returns raw array data. Instead, it returns
type-specific summaries:
- PointCloud: num_points, bounds, classification counts, z-stats
- Mesh: num_vertices, num_faces, bounds, marker info
- Raster: shape, cell_size, bounds, value statistics (min/max/mean/std)
- City: num_buildings, has_terrain, height stats
- Tuples: each element stored separately with linked IDs
Use inspect_object(id) to get a detailed summary of any stored object.
Files: no paths in, no paths out
No tool reads or writes a path given in chat (U1, #10). run_operation refuses every
operation parameter that is a file or directory path (the io.load_* / io.save_* family
and Core's tetgen debug directories), and tests/test_path_refusal.py pins that list
against the pinned Core.
Files a tool produces go into the calling Session's own 0700 directory under
$DTCC_AGENT_ARTIFACTS_DIR (default: the system temp directory; Docker: /data/artifacts),
named with a random token. render_object and export_object return the artifact's name,
never its path. Lurkie serves it at /artifacts/<session>/<name> only while that Session
is live, shows an image inline and a file as a download link, and deletes the directory
when the Session expires. Until central auth (T14), that URL is the credential.
load_geojson reads only from $SHARED_RESULTS_DIR, where dtcc-sim writes results, by a
path relative to it; absolute paths and anything resolving outside it are refused. The MCP
server and Lurkie must see the same artifacts directory; in the two-service deployment
(T13) they share a volume.
Disk cache
Dataset downloads (datasets.point_cloud, datasets.buildings) are
automatically cached to disk in $DTCC_AGENT_CACHE_DIR, by default
$XDG_CACHE_HOME/dtcc_agent (usually ~/.cache/dtcc_agent). Cached objects are
pickles, and loading one runs code, so nobody else may be able to change the
cache: a new directory is created 0700 and its files 0600, and the agent
refuses to start if the directory or any file in it belongs to another user or is
group- or world-writable, or if a parent directory is writable by others without
the sticky bit (as /tmp has), or if anything inside it is a symlink. Several
agent processes may share one directory; index updates are locked. The cache
uses:
- Spatial containment for datasets: if a cached result covers a larger area than requested, the cached data is reused and cropped. Buildings are cropped with Core's own footprint rule, so the result matches a fresh download; a cached object that cannot be cropped is downloaded again.
- No builder results. Builder computations are not cached until their cache
keys are correct (U2, #11). Each builder call is recorded instead, in
$DTCC_AGENT_LOG_DIR/builder_calls.jsonlwhen that is set (Docker:/data/logs), with its duration and the key a cache would have matched, to show how often one would have hit. - TTL eviction (7 days) and a disk budget (10 GB) with oldest-first eviction.
- Version stamp: each entry records the cache format and the dtcc-core commit that wrote it. After a Core upgrade, entries from the old Core are never served and are removed at startup, so the cache starts cold instead of loading objects the new Core may not read.
Both run_operation (via the dispatcher) and get_buildings (direct
MCP tool) check the disk cache before fetching from external sources.
get_buildings caches the building download itself, keyed like
datasets.buildings, and summarises it per request, so a smaller area inside
a cached one is cropped before it is counted.
Provenance
Every answer can be traced to what produced it (T33). Each service records only what it
knows first-hand, and the two records share a turn_id the chatbot mints per message:
answers.jsonl(chatbot, in its log dir; Docker:/data/logs): one line per turn with the model,prompt_version(a hash of the system prompt), whether memory context was added, the SDK version, the tools called, tokens, cost, latency, and whether it failed or was retried fresh. The page also receives it as aprovenanceframe beforedone.operations.jsonl(MCP server, only withDTCC_AGENT_LOG_DIR): one line per tool call with the turn, Session, subject, tool and operation, a hash of the arguments, duration, success, cache hit, the Object references returned and the catalogue revision, plus onecatalogueline each time a process builds its catalogue.
Neither file holds parameter values, error messages or file contents: arguments are
hashed, and an error is reduced to its category (Refused, Invalid bounds, or the
exception's class). To see one complete record per turn:
python -m dtcc_agent.provenance join data/agent/logs # every turn
python -m dtcc_agent.provenance join data/agent/logs --turn turn_1a2b3c4d
Evaluation: the measurement layer
eval/ runs a fixed question set (eval/questions.json, 10 questions from the examples
below and the M1 QA checklist) through the chat and reports latency, tokens and cost per
question (ADR-0008, T34). It measures; it does not score answers right or wrong. The
answer_key field is reserved for the correctness layer, which a domain expert adds later.
export DTCC_AGENT_ACCESS_CODE=... # the running stack's code
python -m eval.measure --runs 3 --log-dir data/agent/logs --max-cost 10
- Fresh chat per question. Every run of every question opens a new chat, so no history leaks between them.
- Cold and warm. Run 1 of each question is cold and later runs are warm (cached downloads), and the report keeps them apart.
- Failed runs. A timeout or error becomes a row with its status. It is left out of the medians and does not stop the run.
- Simulations. Questions that need dtcc-sim are skipped, with the reason, when a probe
question finds no simulations (
--assume-simskips the probe). - Cost cap.
--max-cost(default $2) stops the run cleanly once that much is spent, and the report says so. A timed-out question's cost is not counted, because no provenance frame arrives for it.
Each run writes eval/runs/<time>-<commit>.jsonl and a Markdown report. eval/runs/ is
gitignored; a run worth keeping is committed with git add -f, raw .jsonl in a folder and the
pooled report beside it (for example eval/runs/m3-runtime/ and eval/runs/m3-runtime.md).
--pool RUN.jsonl ... writes one report over several runs.
Comparing two versions: a same-day control
Bedrock's speed drifts by day: on 2026-10-06 unchanged code ran 18% slower than the day before (#87). A comparison against a report from another day therefore partly measures the day. So any comparison that decides something (a gate) runs both sides together:
- Same day, same machine, interleaved: reference, candidate, reference, candidate. Each run
on a fresh stack with its own data folder (
DTCC_AGENT_DATA), so cold runs are cold. - Two runs per side, pooled (
--pool), giving four warm samples per question: single questions varied by up to 50% between identical runs (#90). - A run counts only at 30 of 30 ok without stopping at the cost cap; otherwise it is re-run, not evaluated. A declined request counts as answered (#96).
- Compare the median of per-question warm medians and each question's warm median against the same-day reference. An older committed report is context, not the reference.
- Name what was measured. The report records the checkout the harness ran from; when the
stack runs another image (a control built from an older commit), say so at the top of the
report, as
eval/runs/m3-control.mddoes.
Examples
Example 1: Heatwave impact analysis
User: "How does a heatwave affect Lindholmen in Gothenburg?"
Agent calls: geocode("Lindholmen")
→ bounds [318866, 6399800, 319366, 6400300] in EPSG:3006
Agent calls: get_buildings(bounds)
→ 47 buildings, 3–45m height
Agent calls: compare_scenarios("urban_heat_simulation", bounds,
{air_temperature: 20}, {air_temperature: 38})
→ baseline mean 17.8°C, heatwave mean 36.2°C, max 47.1°C
Agent: "Temperature increases by 18.4°C on average, with dangerous
peaks of 47.1°C in street canyons..."
Example 2: Terrain analysis pipeline
Download a point cloud, build a terrain raster, then compute slope:
User: "Analyze the terrain slope around Chalmers"
Agent calls: geocode("Chalmers")
→ bounds [319700, 6399500, 320200, 6400000]
Agent calls: list_operations(search="terrain")
→ shows datasets.point_cloud, builder.build_terrain_raster,
builder.raster.slope_aspect, ...
Agent calls: run_operation("datasets.point_cloud",
{"bounds": [319700, 6399500, 320200, 6400000]})
→ object_ref: "obj_a1b2c3d4", summary: 1.2M points, z range 5–48m
Agent calls: run_operation("builder.build_terrain_raster",
{"pc": "obj_a1b2c3d4", "cell_size": 2.0})
→ object_ref: "obj_e5f6g7h8", summary: 250×250 raster, elevation 5–48m
Agent calls: run_operation("builder.raster.slope_aspect",
{"dem": "obj_e5f6g7h8"})
→ object_refs: ["obj_f9g0h1i2", "obj_j3k4l5m6"] (slope + aspect rasters)
Agent calls: inspect_object("obj_f9g0h1i2")
→ slope raster: mean 0.12 rad (7°), max 0.8 rad (46°)
Agent: "The terrain around Chalmers is mostly gentle (mean slope 7°)
with some steep sections reaching 46° near the hillside..."
Example 3: Building and tree inventory
User: "What buildings and trees are in Lindholmen?"
Agent calls: geocode("Lindholmen")
→ bounds [318866, 6399800, 319366, 6400300]
Agent calls: run_operation("datasets.buildings",
{"bounds": [318866, 6399800, 319366, 6400300]})
→ object_ref: "obj_b1c2d3e4", 47 buildings, heights 3–45m
Agent calls: run_operation("datasets.point_cloud",
{"bounds": [318866, 6399800, 319366, 6400300]})
→ object_ref: "obj_f5g6h7i8"
Agent calls: run_operation("builder.trees_from_pointcloud",
{"pc": "obj_f5g6h7i8"})
→ object_ref: "obj_j9k0l1m2", 128 trees, height 4–18m
Agent calls: list_objects()
→ 3 objects, 45 MB total
Agent: "Lindholmen has 47 buildings (3–45m tall) and approximately
128 detected trees (4–18m tall) from the LiDAR data..."
Example 4: Visualizing buildings
Download buildings for an area and render them:
User: "Show me the buildings in Lindholmen"
Agent calls: geocode("Lindholmen")
→ bounds [318866, 6399800, 319366, 6400300]
Agent calls: run_operation("datasets.buildings",
{"bounds": [318866, 6399800, 319366, 6400300]})
→ object_ref: "obj_b1c2d3e4", 47 buildings, heights 3–45m
Agent calls: render_object("obj_b1c2d3e4")
→ {"object_ref": "obj_b1c2d3e4", "type": "BuildingCollection",
"artifact": {"name": "3f9c…_obj_b1c2d3e4.png", "kind": "image"}}
(Lurkie shows the footprints, drawn in plan view, in the chat)
Agent: "Here are the 47 building footprints in Lindholmen.
The tallest building reaches 45m..."
Example 5: Terrain visualization pipeline
Build a terrain raster and render it:
User: "Visualize the terrain around Chalmers"
Agent calls: geocode("Chalmers")
→ bounds [319700, 6399500, 320200, 6400000]
Agent calls: run_operation("datasets.point_cloud",
{"bounds": [319700, 6399500, 320200, 6400000]})
→ object_ref: "obj_a1b2c3d4", 1.2M points
Agent calls: run_operation("builder.build_terrain_raster",
{"pc": "obj_a1b2c3d4", "cell_size": 2.0})
→ object_ref: "obj_e5f6g7h8", 250×250 raster, elevation 5–48m
Agent calls: render_object("obj_e5f6g7h8")
→ {"object_ref": "obj_e5f6g7h8", "type": "Raster",
"artifact": {"name": "8a1d…_obj_e5f6g7h8.png", "kind": "image"}}
Agent: "Here's the terrain raster for the Chalmers area. Elevation
ranges from 5m near the waterfront to 48m on the hillside..."
Example 6: Discovering operations
Agent calls: list_operations(category="builder")
→ 73 operations: build_terrain_raster, build_city_surface_mesh,
trees_from_pointcloud, merge_meshes, slope_aspect, ...
Agent calls: describe_operation("builder.build_city_surface_mesh")
→ params: city (object_ref, required), max_mesh_size (float, default 10),
min_mesh_angle (float, default 20.7), ...
Agent calls: describe_operation("builder.pc_filter.remove_vegetation")
→ params: pc (object_ref, required)
→ returns: PointCloud
Web Chatbot (Lurkie)
The chatbot provides a browser-based chat interface. Its agent loop runs on pydantic-ai with Claude on Amazon Bedrock.
Install
pip install -e ".[chatbot]"
Run
python -m chatbot
Then open http://localhost:8050 in your browser. Logs are written to
/tmp/dtcc_lurkie_logs/.
How it works
The chatbot connects to dtcc-agent as an MCP server. User messages are sent via WebSocket, and Claude's responses (including tool calls and 3D renders) stream back in real-time.
Features:
- Conversation memory: Past exchanges are stored in ChromaDB and retrieved via RAG to provide cross-session context.
- Error recovery: If a follow-up turn fails (e.g. context limit), the chatbot retries it once as a fresh conversation.
- Session management: A chat ends after 60 minutes without a message. A "New Chat" button lets users start fresh without reloading.
- Security: Rendered markdown is sanitized with DOMPurify to prevent XSS.
License
MIT License. This project is part of the DTCC Platform developed at the Digital Twin Cities Centre.