城市数字孪生仿真代理

dtcc-agent 是一个面向城市数字孪生仿真的 LLM 代理服务,通过 MCP 协议将地理编码、建筑数据检索、环境模拟与场景对比等能力暴露给 Claude 等大语言模型,支持以自然语言完成城市环境数据分析与仿真结果解读。

  • by dtcc-platform
  • Python
  • 混合部署
  • 更新于
在 GitHub 查看 dtcc-platform/dtcc-agent

使用说明

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_URL set: each simulation is a DTCC Engine job (dtcc-twin apps/engine). The agent submits it, polls it, and downloads the result as a .dtccpkg that 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-sim mini-service through the same dtcc-core remote 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 native dtcc format 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_core and dtcc_sim directly in-process. This requires the full scientific environment.

Prerequisites

For the Docker mini-service, you need:

  • Docker
  • A running dtcc-sim mini-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 binds 127.0.0.1:8051 and that port is never published (U11), so nothing outside the pair can reach it, and every call must also carry DTCC_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 at http://127.0.0.1:8051/mcp, sending its session id in X-DTCC-Session and 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:

  • Dockerfile
  • docker-compose.yml
  • build_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.jsonl when 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 a provenance frame before done.
  • operations.jsonl (MCP server, only with DTCC_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 one catalogue line 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-sim skips 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.md does.

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.