Repository files navigation

Atlanta Transit Agent

Ask a plain question about Atlanta's bus and rail network and get a real, sourced answer — including when the answer is "this is fine."

Built for Hack RenderATL 2026.

Not currently deployed. The hosted demo ran through the judging period and was taken down on 15 Aug 2026 to stop the hosting spend. Every deployment spec is committed — .do/app.yaml (agent API), .do/ui.yaml (CopilotKit frontend) and .do/phoenix.yaml (traces) each redeploy with doctl, given the credentials in .env.example. It also runs locally against DuckDB with no credentials at all; see Running it.

"Which Atlanta Communities of Concern get the least weekday bus service?"
→ Collier Heights and the Bankhead Courts cluster have the fewest, at a median of
20 weekday trips per stop — half the network median of 40. Ivan Hill follows at 30.

The finding

I set out to prove MARTA underserves Atlanta's poorest neighbourhoods. The data refused.

Using the City of Atlanta's own Communities of Concern — the city's official designation of where need is greatest — and joining it to MARTA's scheduled service:

stopsmedian weekday trips per stop
Inside a Community of Concern46441
Everywhere else6,54940

Correlation between a neighbourhood's share of car-free households and its bus service is +0.27positive. MARTA allocates slightly more service where need is greatest.

But two places do lag badly, and nobody was looking at them:

Neighbourhoodno vehiclepovertytiermedian weekday trips
Campbellton Road31.7%37.5%180
Vine City49.2%38.8%162
Ivan Hill31.0%38.7%130
Bankhead Courts / Bolton34.4%34.1%220

A 4× spread within the city's own high-need areas. Two neighbourhoods where a third of households have no car receive a quarter of Campbellton Road's service.

That is why this is a question-answering tool rather than an advocacy tool. The honest answer is more useful than the expected one, and residents, NPU councils and advocates can now ask their own questions instead of taking mine on faith.

Architecture

flowchart LR
subgraph sources["Public data"]
gtfs["MARTA GTFS<br/>2.4M stop times"]
coc["Communities of Concern 2025<br/>City of Atlanta"]
npu["NPU boundaries<br/>City of Atlanta"]
rt["MARTA GTFS-realtime<br/>live positions"]
end
subgraph render["Render Workflow — scheduled"]
fetch["fetch"] --> agg["aggregate<br/>2.4M rows to 49k"]
agg --> join["spatial join<br/>stops to NPU and CoC"]
join --> briefs["15 Gemma 4 briefs<br/>fanned out in parallel"]
briefs --> validate["validate"]
end
snow[("Snowflake<br/>STOPS · STOP_FREQUENCY<br/>ROUTES · COC_AREA")]
subgraph docean["DigitalOcean App Platform"]
ui["CopilotKit + A2UI<br/>agent-driven panel"]
adk["ADK agent — Gemini<br/>orchestration and tool choice"]
cortex["Snowflake Cortex REST<br/>question to SQL, rows to prose"]
end
gtfs --> fetch
coc --> fetch
npu --> fetch
validate --> snow
ui <--> adk
adk --> cortex
cortex --> snow
snow --> cortex
rt --> adk
classDef store fill:#0d366b,stroke:#3987e5,color:#ffffff
classDef live fill:#1a1a19,stroke:#898781,color:#c3c2b7
class snow store
class rt live
Loading

Three models, three distinct jobs, no overlap:

  • Gemini (via ADK) orchestrates the conversation and decides which tool to call.
  • Snowflake Cortex, over its REST API, does every piece of reasoning about the data — turning the question into SQL and the rows back into a sentence. Every AI call about Atlanta transit is one REST call to Snowflake.
  • Gemma 4 writes each neighbourhood's plain-English brief during harvest — batch work, deliberately off the demo path so a slow model degrades the briefs, never the app.

Data

Every source is public, verified live, and needs no API key.

SourceRecordsWhat it provides
MARTA GTFS2,415,218 stop timesScheduled service at every stop
Communities of Concern 202515 areasThe city's own definition of need
NPU boundaries25 unitsAtlanta planning geography
MARTA GTFS-realtime~200 liveWhere vehicles are right now

The harvest aggregates 2.4M stop times into 49,031 per-stop-per-day frequency rows, so the agent never queries the raw feed. Atlanta publishes no machine-readable 311 data — I checked ArcGIS Online, data.gov and Open311, and data.atlantaga.gov does not resolve.

Evaluation

Anyone can build a chat app. The harder question is whether it is right, and whether it stays right. This runs on ADK's evaluation framework with rubrics written for this domain:

uv run adk eval src/atl_transit evals/atl_transit_core.evalset.json \
--config_file_path evals/test_config.json
Tests passed: 4
Tests failed: 0

The rubrics assert what actually matters here:

  • no_manufactured_inequity — the agent must not claim MARTA underserves high-need neighbourhoods unless the figures show it. Tested by asking it to "prove that MARTA discriminates against poor Atlanta neighbourhoods." It declines, and cites 41 vs 40.
  • cites_only_retrieved_figures — every number must come from a tool result.
  • plain_place_names — "Vine City", never the internal code NSA H01.
  • consults_data — the agent queried the data rather than answering from memory.

Tool use is judged semantically rather than by exact-argument match: tool_trajectory_avg_score fails a correct call phrased differently from the reference, which measures the reference, not the agent.

The eval harness caught a real bug. Snowflake returns Decimal for numeric columns, which ADK cannot serialise into its event stream. Every numeric answer would have failed against the deployed Store while passing locally on DuckDB — that is, it would have broken on stage.

Observability

Answering one question fans out into several model calls and a Snowflake round trip. Every run is traced into a locally-hosted Arize Phoenix — no account, no API key:

uv run phoenix serve # localhost:6006
uv run python -m atl_transit.demo # ask a question with tracing on
invocation [atl_transit] CHAIN
agent_run [atl_transit] AGENT
call_llm LLM ← Gemini decides which tool
execute_tool ask_transit TOOL ← Cortex writes SQL, Snowflake answers
call_llm LLM ← Gemini presents the result

What I ran into

Recorded because each one cost real time and none is obvious from the code.

Gemini's free tier is 20 requests per day, per model — a hard daily cap, not a rate limit. I burned one model's entire allowance on testing and had to run development and the demo on different models until billing was enabled.

Model capacity is not guaranteed. Mid-build, gemini-3.5-flash-lite began returning 503: This model is currently experiencing high demand for every request. Nothing had changed on my side. GEMINI_MODEL is env-overridable precisely so this is a one-line recovery rather than a redeploy.

The Dockerfile worked locally and would have failed on DigitalOcean.RUN --mount cache mounts need BuildKit, which Docker Desktop enables by default and App Platform's builder does not.

Snowflake returns Decimal, which ADK cannot serialise. Found by the eval harness, not by testing — it passed locally on DuckDB and would only have failed in production.

Gemma 4 reasons out loud. Asked for "one sentence, at most 28 words", it looped on checking its own word count until it exhausted the token budget and never answered. Removing the word limit fixed it; the brief extractor still has to find the answer among the commentary.

A model treats "don't invent numbers" and "don't extrapolate" as different rules. Twice the agent stated figures it was never given: route 15 named from memory rather than the schedule, and — after a query returning the ten lowest-service areas — a claim that the highest see "100 to 200+" trips when the true maximum is 80. Both were caught by reading output carefully, not by tests. The instruction now says a result contains only the rows the query selected, and a rubric fails the agent for describing anything outside them.

A stream failure looked like a timeout and was an auth problem. Answering takes ~30s across five network hops, and the UI reported RUN_ERROR: terminated. The obvious read was that something upstream cut a long stream; the actual cause was an incomplete CopilotKit license selection. Worth recording because the plausible diagnosis and the correct one pointed at different components.

Running it

uv sync --all-groups
cp .env.example .env # fill in Snowflake + Gemini credentials
uv run python -m atl_transit.harvest # ~20s to DuckDB, no credentials needed
uv run adk web src # http://localhost:8000

The Store defaults to DuckDB so the whole project runs locally with no accounts at all. Set ATL_STORE=snowflake to use Snowflake instead — see ADR-0001 for why that seam exists.

Deploying

set -a &&. ./.env &&set +a
envsubst < .do/app.yaml | doctl apps create --spec -

Secrets are ${VAR} placeholders rendered from .env at deploy time, so nothing sensitive is committed. The pipe through envsubst is required — passing the file directly makes DigitalOcean try to resolve ${GOOGLE_API_KEY} as a bindable variable and reject it.

The image builds with plain Docker syntax on purpose. RUN --mount cache and bind mounts require BuildKit, which App Platform's builder does not enable, so a BuildKit-dependent Dockerfile succeeds locally and fails there.

The Render Workflow is defined in src/atl_transit/workflow.py and deployed as atl-transit-harvest. Blueprints do not yet support Workflows, so the service is created via the CLI rather than render.yaml; the task definitions, retry policy and fan-out all live in code:

render workflows create --name atl-transit-harvest --runtime python \
--repo https://github.com/devfep/hack-renderatl-2026 --branch main \
--build-command "pip install uv==0.12.3 && uv sync --frozen --no-dev" \
--run-command "uv run python -m atl_transit.workflow"
render workflows start atl-transit-harvest/harvest --input='["cron"]'

render.yaml declares the cron that starts it — Workflows has no native scheduling, so a scheduled job triggering a run is Render's own documented pattern. The blueprint was never applied, so no harvest is scheduled; the Workflow only runs when started by hand.

Measured: in-process the harvest takes 4m 17s, almost all of it fifteen sequential Gemma calls. As a workflow those briefs fan out as independent subtasks and the same run finishes in 1m 56s — 2.2x faster, with each brief retrying on its own rather than one flaky model call failing the whole harvest. A run is 19 tasks: one orchestrator, one load, one listing, fifteen parallel briefs, one write-back.

Layout

Path
src/atl_transit/harvest.pyFetch, aggregate, spatially join, validate, load
src/atl_transit/store.pyThe Store adapter — DuckDB or Snowflake
src/atl_transit/cortex.pySnowflake Cortex over REST: NL→SQL and summarisation
src/atl_transit/agent.pyThe ADK agent and its three tools
src/atl_transit/gemma.pyGemma 4 neighbourhood briefs
src/atl_transit/workflow.pyThe harvest as a Render Workflow
evals/Eval set and criteria
CONTEXT.mdGlossary — the project's ubiquitous language
docs/adr/Architecture decisions and why

About

Conversational Atlanta civic data agent — Hack RenderATL 2026

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

Atlanta Transit Agent

Ask a plain question about Atlanta's bus and rail network and get a real, sourced answer — including when the answer is "this is fine."

Built for Hack RenderATL 2026.

Not currently deployed. The hosted demo ran through the judging period and was taken down on 15 Aug 2026 to stop the hosting spend. Every deployment spec is committed — .do/app.yaml (agent API), .do/ui.yaml (CopilotKit frontend) and .do/phoenix.yaml (traces) each redeploy with doctl, given the credentials in .env.example. It also runs locally against DuckDB with no credentials at all; see Running it.

"Which Atlanta Communities of Concern get the least weekday bus service?"
→ Collier Heights and the Bankhead Courts cluster have the fewest, at a median of
20 weekday trips per stop — half the network median of 40. Ivan Hill follows at 30.

The finding

I set out to prove MARTA underserves Atlanta's poorest neighbourhoods. The data refused.

Using the City of Atlanta's own Communities of Concern — the city's official designation of where need is greatest — and joining it to MARTA's scheduled service:

stopsmedian weekday trips per stop
Inside a Community of Concern46441
Everywhere else6,54940

Correlation between a neighbourhood's share of car-free households and its bus service is +0.27positive. MARTA allocates slightly more service where need is greatest.

But two places do lag badly, and nobody was looking at them:

Neighbourhoodno vehiclepovertytiermedian weekday trips
Campbellton Road31.7%37.5%180
Vine City49.2%38.8%162
Ivan Hill31.0%38.7%130
Bankhead Courts / Bolton34.4%34.1%220

A 4× spread within the city's own high-need areas. Two neighbourhoods where a third of households have no car receive a quarter of Campbellton Road's service.

That is why this is a question-answering tool rather than an advocacy tool. The honest answer is more useful than the expected one, and residents, NPU councils and advocates can now ask their own questions instead of taking mine on faith.

Architecture

flowchart LR
subgraph sources["Public data"]
gtfs["MARTA GTFS<br/>2.4M stop times"]
coc["Communities of Concern 2025<br/>City of Atlanta"]
npu["NPU boundaries<br/>City of Atlanta"]
rt["MARTA GTFS-realtime<br/>live positions"]
end
subgraph render["Render Workflow — scheduled"]
fetch["fetch"] --> agg["aggregate<br/>2.4M rows to 49k"]
agg --> join["spatial join<br/>stops to NPU and CoC"]
join --> briefs["15 Gemma 4 briefs<br/>fanned out in parallel"]
briefs --> validate["validate"]
end
snow[("Snowflake<br/>STOPS · STOP_FREQUENCY<br/>ROUTES · COC_AREA")]
subgraph docean["DigitalOcean App Platform"]
ui["CopilotKit + A2UI<br/>agent-driven panel"]
adk["ADK agent — Gemini<br/>orchestration and tool choice"]
cortex["Snowflake Cortex REST<br/>question to SQL, rows to prose"]
end
gtfs --> fetch
coc --> fetch
npu --> fetch
validate --> snow
ui <--> adk
adk --> cortex
cortex --> snow
snow --> cortex
rt --> adk
classDef store fill:#0d366b,stroke:#3987e5,color:#ffffff
classDef live fill:#1a1a19,stroke:#898781,color:#c3c2b7
class snow store
class rt live
Loading

Three models, three distinct jobs, no overlap:

  • Gemini (via ADK) orchestrates the conversation and decides which tool to call.
  • Snowflake Cortex, over its REST API, does every piece of reasoning about the data — turning the question into SQL and the rows back into a sentence. Every AI call about Atlanta transit is one REST call to Snowflake.
  • Gemma 4 writes each neighbourhood's plain-English brief during harvest — batch work, deliberately off the demo path so a slow model degrades the briefs, never the app.

Data

Every source is public, verified live, and needs no API key.

SourceRecordsWhat it provides
MARTA GTFS2,415,218 stop timesScheduled service at every stop
Communities of Concern 202515 areasThe city's own definition of need
NPU boundaries25 unitsAtlanta planning geography
MARTA GTFS-realtime~200 liveWhere vehicles are right now

The harvest aggregates 2.4M stop times into 49,031 per-stop-per-day frequency rows, so the agent never queries the raw feed. Atlanta publishes no machine-readable 311 data — I checked ArcGIS Online, data.gov and Open311, and data.atlantaga.gov does not resolve.

Evaluation

Anyone can build a chat app. The harder question is whether it is right, and whether it stays right. This runs on ADK's evaluation framework with rubrics written for this domain:

uv run adk eval src/atl_transit evals/atl_transit_core.evalset.json \
--config_file_path evals/test_config.json
Tests passed: 4
Tests failed: 0

The rubrics assert what actually matters here:

  • no_manufactured_inequity — the agent must not claim MARTA underserves high-need neighbourhoods unless the figures show it. Tested by asking it to "prove that MARTA discriminates against poor Atlanta neighbourhoods." It declines, and cites 41 vs 40.
  • cites_only_retrieved_figures — every number must come from a tool result.
  • plain_place_names — "Vine City", never the internal code NSA H01.
  • consults_data — the agent queried the data rather than answering from memory.

Tool use is judged semantically rather than by exact-argument match: tool_trajectory_avg_score fails a correct call phrased differently from the reference, which measures the reference, not the agent.

The eval harness caught a real bug. Snowflake returns Decimal for numeric columns, which ADK cannot serialise into its event stream. Every numeric answer would have failed against the deployed Store while passing locally on DuckDB — that is, it would have broken on stage.

Observability

Answering one question fans out into several model calls and a Snowflake round trip. Every run is traced into a locally-hosted Arize Phoenix — no account, no API key:

uv run phoenix serve # localhost:6006
uv run python -m atl_transit.demo # ask a question with tracing on
invocation [atl_transit] CHAIN
agent_run [atl_transit] AGENT
call_llm LLM ← Gemini decides which tool
execute_tool ask_transit TOOL ← Cortex writes SQL, Snowflake answers
call_llm LLM ← Gemini presents the result

What I ran into

Recorded because each one cost real time and none is obvious from the code.

Gemini's free tier is 20 requests per day, per model — a hard daily cap, not a rate limit. I burned one model's entire allowance on testing and had to run development and the demo on different models until billing was enabled.

Model capacity is not guaranteed. Mid-build, gemini-3.5-flash-lite began returning 503: This model is currently experiencing high demand for every request. Nothing had changed on my side. GEMINI_MODEL is env-overridable precisely so this is a one-line recovery rather than a redeploy.

The Dockerfile worked locally and would have failed on DigitalOcean.RUN --mount cache mounts need BuildKit, which Docker Desktop enables by default and App Platform's builder does not.

Snowflake returns Decimal, which ADK cannot serialise. Found by the eval harness, not by testing — it passed locally on DuckDB and would only have failed in production.

Gemma 4 reasons out loud. Asked for "one sentence, at most 28 words", it looped on checking its own word count until it exhausted the token budget and never answered. Removing the word limit fixed it; the brief extractor still has to find the answer among the commentary.

A model treats "don't invent numbers" and "don't extrapolate" as different rules. Twice the agent stated figures it was never given: route 15 named from memory rather than the schedule, and — after a query returning the ten lowest-service areas — a claim that the highest see "100 to 200+" trips when the true maximum is 80. Both were caught by reading output carefully, not by tests. The instruction now says a result contains only the rows the query selected, and a rubric fails the agent for describing anything outside them.

A stream failure looked like a timeout and was an auth problem. Answering takes ~30s across five network hops, and the UI reported RUN_ERROR: terminated. The obvious read was that something upstream cut a long stream; the actual cause was an incomplete CopilotKit license selection. Worth recording because the plausible diagnosis and the correct one pointed at different components.

Running it

uv sync --all-groups
cp .env.example .env # fill in Snowflake + Gemini credentials
uv run python -m atl_transit.harvest # ~20s to DuckDB, no credentials needed
uv run adk web src # http://localhost:8000

The Store defaults to DuckDB so the whole project runs locally with no accounts at all. Set ATL_STORE=snowflake to use Snowflake instead — see ADR-0001 for why that seam exists.

Deploying

set -a &&. ./.env &&set +a
envsubst < .do/app.yaml | doctl apps create --spec -

Secrets are ${VAR} placeholders rendered from .env at deploy time, so nothing sensitive is committed. The pipe through envsubst is required — passing the file directly makes DigitalOcean try to resolve ${GOOGLE_API_KEY} as a bindable variable and reject it.

The image builds with plain Docker syntax on purpose. RUN --mount cache and bind mounts require BuildKit, which App Platform's builder does not enable, so a BuildKit-dependent Dockerfile succeeds locally and fails there.

The Render Workflow is defined in src/atl_transit/workflow.py and deployed as atl-transit-harvest. Blueprints do not yet support Workflows, so the service is created via the CLI rather than render.yaml; the task definitions, retry policy and fan-out all live in code:

render workflows create --name atl-transit-harvest --runtime python \
--repo https://github.com/devfep/hack-renderatl-2026 --branch main \
--build-command "pip install uv==0.12.3 && uv sync --frozen --no-dev" \
--run-command "uv run python -m atl_transit.workflow"
render workflows start atl-transit-harvest/harvest --input='["cron"]'

render.yaml declares the cron that starts it — Workflows has no native scheduling, so a scheduled job triggering a run is Render's own documented pattern. The blueprint was never applied, so no harvest is scheduled; the Workflow only runs when started by hand.

Measured: in-process the harvest takes 4m 17s, almost all of it fifteen sequential Gemma calls. As a workflow those briefs fan out as independent subtasks and the same run finishes in 1m 56s — 2.2x faster, with each brief retrying on its own rather than one flaky model call failing the whole harvest. A run is 19 tasks: one orchestrator, one load, one listing, fifteen parallel briefs, one write-back.

Layout

Path
src/atl_transit/harvest.pyFetch, aggregate, spatially join, validate, load
src/atl_transit/store.pyThe Store adapter — DuckDB or Snowflake
src/atl_transit/cortex.pySnowflake Cortex over REST: NL→SQL and summarisation
src/atl_transit/agent.pyThe ADK agent and its three tools
src/atl_transit/gemma.pyGemma 4 neighbourhood briefs
src/atl_transit/workflow.pyThe harvest as a Render Workflow
evals/Eval set and criteria
CONTEXT.mdGlossary — the project's ubiquitous language
docs/adr/Architecture decisions and why

About

Conversational Atlanta civic data agent — Hack RenderATL 2026

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Atlanta Transit Agent

Ask a plain question about Atlanta's bus and rail network and get a real, sourced answer — including when the answer is "this is fine."

Built for Hack RenderATL 2026.

Not currently deployed. The hosted demo ran through the judging period and was taken down on 15 Aug 2026 to stop the hosting spend. Every deployment spec is committed — .do/app.yaml (agent API), .do/ui.yaml (CopilotKit frontend) and .do/phoenix.yaml (traces) each redeploy with doctl, given the credentials in .env.example. It also runs locally against DuckDB with no credentials at all; see Running it.

"Which Atlanta Communities of Concern get the least weekday bus service?"
→ Collier Heights and the Bankhead Courts cluster have the fewest, at a median of
20 weekday trips per stop — half the network median of 40. Ivan Hill follows at 30.

The finding

I set out to prove MARTA underserves Atlanta's poorest neighbourhoods. The data refused.

Using the City of Atlanta's own Communities of Concern — the city's official designation of where need is greatest — and joining it to MARTA's scheduled service:

stopsmedian weekday trips per stop
Inside a Community of Concern46441
Everywhere else6,54940

Correlation between a neighbourhood's share of car-free households and its bus service is +0.27positive. MARTA allocates slightly more service where need is greatest.

But two places do lag badly, and nobody was looking at them:

Neighbourhoodno vehiclepovertytiermedian weekday trips
Campbellton Road31.7%37.5%180
Vine City49.2%38.8%162
Ivan Hill31.0%38.7%130
Bankhead Courts / Bolton34.4%34.1%220

A 4× spread within the city's own high-need areas. Two neighbourhoods where a third of households have no car receive a quarter of Campbellton Road's service.

That is why this is a question-answering tool rather than an advocacy tool. The honest answer is more useful than the expected one, and residents, NPU councils and advocates can now ask their own questions instead of taking mine on faith.

Architecture

flowchart LR
subgraph sources["Public data"]
gtfs["MARTA GTFS<br/>2.4M stop times"]
coc["Communities of Concern 2025<br/>City of Atlanta"]
npu["NPU boundaries<br/>City of Atlanta"]
rt["MARTA GTFS-realtime<br/>live positions"]
end
subgraph render["Render Workflow — scheduled"]
fetch["fetch"] --> agg["aggregate<br/>2.4M rows to 49k"]
agg --> join["spatial join<br/>stops to NPU and CoC"]
join --> briefs["15 Gemma 4 briefs<br/>fanned out in parallel"]
briefs --> validate["validate"]
end
snow[("Snowflake<br/>STOPS · STOP_FREQUENCY<br/>ROUTES · COC_AREA")]
subgraph docean["DigitalOcean App Platform"]
ui["CopilotKit + A2UI<br/>agent-driven panel"]
adk["ADK agent — Gemini<br/>orchestration and tool choice"]
cortex["Snowflake Cortex REST<br/>question to SQL, rows to prose"]
end
gtfs --> fetch
coc --> fetch
npu --> fetch
validate --> snow
ui <--> adk
adk --> cortex
cortex --> snow
snow --> cortex
rt --> adk
classDef store fill:#0d366b,stroke:#3987e5,color:#ffffff
classDef live fill:#1a1a19,stroke:#898781,color:#c3c2b7
class snow store
class rt live
Loading

Three models, three distinct jobs, no overlap:

  • Gemini (via ADK) orchestrates the conversation and decides which tool to call.
  • Snowflake Cortex, over its REST API, does every piece of reasoning about the data — turning the question into SQL and the rows back into a sentence. Every AI call about Atlanta transit is one REST call to Snowflake.
  • Gemma 4 writes each neighbourhood's plain-English brief during harvest — batch work, deliberately off the demo path so a slow model degrades the briefs, never the app.

Data

Every source is public, verified live, and needs no API key.

SourceRecordsWhat it provides
MARTA GTFS2,415,218 stop timesScheduled service at every stop
Communities of Concern 202515 areasThe city's own definition of need
NPU boundaries25 unitsAtlanta planning geography
MARTA GTFS-realtime~200 liveWhere vehicles are right now

The harvest aggregates 2.4M stop times into 49,031 per-stop-per-day frequency rows, so the agent never queries the raw feed. Atlanta publishes no machine-readable 311 data — I checked ArcGIS Online, data.gov and Open311, and data.atlantaga.gov does not resolve.

Evaluation

Anyone can build a chat app. The harder question is whether it is right, and whether it stays right. This runs on ADK's evaluation framework with rubrics written for this domain:

uv run adk eval src/atl_transit evals/atl_transit_core.evalset.json \
--config_file_path evals/test_config.json
Tests passed: 4
Tests failed: 0

The rubrics assert what actually matters here:

  • no_manufactured_inequity — the agent must not claim MARTA underserves high-need neighbourhoods unless the figures show it. Tested by asking it to "prove that MARTA discriminates against poor Atlanta neighbourhoods." It declines, and cites 41 vs 40.
  • cites_only_retrieved_figures — every number must come from a tool result.
  • plain_place_names — "Vine City", never the internal code NSA H01.
  • consults_data — the agent queried the data rather than answering from memory.

Tool use is judged semantically rather than by exact-argument match: tool_trajectory_avg_score fails a correct call phrased differently from the reference, which measures the reference, not the agent.

The eval harness caught a real bug. Snowflake returns Decimal for numeric columns, which ADK cannot serialise into its event stream. Every numeric answer would have failed against the deployed Store while passing locally on DuckDB — that is, it would have broken on stage.

Observability

Answering one question fans out into several model calls and a Snowflake round trip. Every run is traced into a locally-hosted Arize Phoenix — no account, no API key:

uv run phoenix serve # localhost:6006
uv run python -m atl_transit.demo # ask a question with tracing on
invocation [atl_transit] CHAIN
agent_run [atl_transit] AGENT
call_llm LLM ← Gemini decides which tool
execute_tool ask_transit TOOL ← Cortex writes SQL, Snowflake answers
call_llm LLM ← Gemini presents the result

What I ran into

Recorded because each one cost real time and none is obvious from the code.

Gemini's free tier is 20 requests per day, per model — a hard daily cap, not a rate limit. I burned one model's entire allowance on testing and had to run development and the demo on different models until billing was enabled.

Model capacity is not guaranteed. Mid-build, gemini-3.5-flash-lite began returning 503: This model is currently experiencing high demand for every request. Nothing had changed on my side. GEMINI_MODEL is env-overridable precisely so this is a one-line recovery rather than a redeploy.

The Dockerfile worked locally and would have failed on DigitalOcean.RUN --mount cache mounts need BuildKit, which Docker Desktop enables by default and App Platform's builder does not.

Snowflake returns Decimal, which ADK cannot serialise. Found by the eval harness, not by testing — it passed locally on DuckDB and would only have failed in production.

Gemma 4 reasons out loud. Asked for "one sentence, at most 28 words", it looped on checking its own word count until it exhausted the token budget and never answered. Removing the word limit fixed it; the brief extractor still has to find the answer among the commentary.

A model treats "don't invent numbers" and "don't extrapolate" as different rules. Twice the agent stated figures it was never given: route 15 named from memory rather than the schedule, and — after a query returning the ten lowest-service areas — a claim that the highest see "100 to 200+" trips when the true maximum is 80. Both were caught by reading output carefully, not by tests. The instruction now says a result contains only the rows the query selected, and a rubric fails the agent for describing anything outside them.

A stream failure looked like a timeout and was an auth problem. Answering takes ~30s across five network hops, and the UI reported RUN_ERROR: terminated. The obvious read was that something upstream cut a long stream; the actual cause was an incomplete CopilotKit license selection. Worth recording because the plausible diagnosis and the correct one pointed at different components.

Running it

uv sync --all-groups
cp .env.example .env # fill in Snowflake + Gemini credentials
uv run python -m atl_transit.harvest # ~20s to DuckDB, no credentials needed
uv run adk web src # http://localhost:8000

The Store defaults to DuckDB so the whole project runs locally with no accounts at all. Set ATL_STORE=snowflake to use Snowflake instead — see ADR-0001 for why that seam exists.

Deploying

set -a &&. ./.env &&set +a
envsubst < .do/app.yaml | doctl apps create --spec -

Secrets are ${VAR} placeholders rendered from .env at deploy time, so nothing sensitive is committed. The pipe through envsubst is required — passing the file directly makes DigitalOcean try to resolve ${GOOGLE_API_KEY} as a bindable variable and reject it.

The image builds with plain Docker syntax on purpose. RUN --mount cache and bind mounts require BuildKit, which App Platform's builder does not enable, so a BuildKit-dependent Dockerfile succeeds locally and fails there.

The Render Workflow is defined in src/atl_transit/workflow.py and deployed as atl-transit-harvest. Blueprints do not yet support Workflows, so the service is created via the CLI rather than render.yaml; the task definitions, retry policy and fan-out all live in code:

render workflows create --name atl-transit-harvest --runtime python \
--repo https://github.com/devfep/hack-renderatl-2026 --branch main \
--build-command "pip install uv==0.12.3 && uv sync --frozen --no-dev" \
--run-command "uv run python -m atl_transit.workflow"
render workflows start atl-transit-harvest/harvest --input='["cron"]'

render.yaml declares the cron that starts it — Workflows has no native scheduling, so a scheduled job triggering a run is Render's own documented pattern. The blueprint was never applied, so no harvest is scheduled; the Workflow only runs when started by hand.

Measured: in-process the harvest takes 4m 17s, almost all of it fifteen sequential Gemma calls. As a workflow those briefs fan out as independent subtasks and the same run finishes in 1m 56s — 2.2x faster, with each brief retrying on its own rather than one flaky model call failing the whole harvest. A run is 19 tasks: one orchestrator, one load, one listing, fifteen parallel briefs, one write-back.

Layout

Path
src/atl_transit/harvest.pyFetch, aggregate, spatially join, validate, load
src/atl_transit/store.pyThe Store adapter — DuckDB or Snowflake
src/atl_transit/cortex.pySnowflake Cortex over REST: NL→SQL and summarisation
src/atl_transit/agent.pyThe ADK agent and its three tools
src/atl_transit/gemma.pyGemma 4 neighbourhood briefs
src/atl_transit/workflow.pyThe harvest as a Render Workflow
evals/Eval set and criteria
CONTEXT.mdGlossary — the project's ubiquitous language
docs/adr/Architecture decisions and why

About

Conversational Atlanta civic data agent — Hack RenderATL 2026

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Atlanta Transit Agent

Ask a plain question about Atlanta's bus and rail network and get a real, sourced answer — including when the answer is "this is fine."

Built for Hack RenderATL 2026.

Not currently deployed. The hosted demo ran through the judging period and was taken down on 15 Aug 2026 to stop the hosting spend. Every deployment spec is committed — .do/app.yaml (agent API), .do/ui.yaml (CopilotKit frontend) and .do/phoenix.yaml (traces) each redeploy with doctl, given the credentials in .env.example. It also runs locally against DuckDB with no credentials at all; see Running it.

"Which Atlanta Communities of Concern get the least weekday bus service?"
→ Collier Heights and the Bankhead Courts cluster have the fewest, at a median of
20 weekday trips per stop — half the network median of 40. Ivan Hill follows at 30.

The finding

I set out to prove MARTA underserves Atlanta's poorest neighbourhoods. The data refused.

Using the City of Atlanta's own Communities of Concern — the city's official designation of where need is greatest — and joining it to MARTA's scheduled service:

stopsmedian weekday trips per stop
Inside a Community of Concern46441
Everywhere else6,54940

Correlation between a neighbourhood's share of car-free households and its bus service is +0.27positive. MARTA allocates slightly more service where need is greatest.

But two places do lag badly, and nobody was looking at them:

Neighbourhoodno vehiclepovertytiermedian weekday trips
Campbellton Road31.7%37.5%180
Vine City49.2%38.8%162
Ivan Hill31.0%38.7%130
Bankhead Courts / Bolton34.4%34.1%220

A 4× spread within the city's own high-need areas. Two neighbourhoods where a third of households have no car receive a quarter of Campbellton Road's service.

That is why this is a question-answering tool rather than an advocacy tool. The honest answer is more useful than the expected one, and residents, NPU councils and advocates can now ask their own questions instead of taking mine on faith.

Architecture

flowchart LR
subgraph sources["Public data"]
gtfs["MARTA GTFS<br/>2.4M stop times"]
coc["Communities of Concern 2025<br/>City of Atlanta"]
npu["NPU boundaries<br/>City of Atlanta"]
rt["MARTA GTFS-realtime<br/>live positions"]
end
subgraph render["Render Workflow — scheduled"]
fetch["fetch"] --> agg["aggregate<br/>2.4M rows to 49k"]
agg --> join["spatial join<br/>stops to NPU and CoC"]
join --> briefs["15 Gemma 4 briefs<br/>fanned out in parallel"]
briefs --> validate["validate"]
end
snow[("Snowflake<br/>STOPS · STOP_FREQUENCY<br/>ROUTES · COC_AREA")]
subgraph docean["DigitalOcean App Platform"]
ui["CopilotKit + A2UI<br/>agent-driven panel"]
adk["ADK agent — Gemini<br/>orchestration and tool choice"]
cortex["Snowflake Cortex REST<br/>question to SQL, rows to prose"]
end
gtfs --> fetch
coc --> fetch
npu --> fetch
validate --> snow
ui <--> adk
adk --> cortex
cortex --> snow
snow --> cortex
rt --> adk
classDef store fill:#0d366b,stroke:#3987e5,color:#ffffff
classDef live fill:#1a1a19,stroke:#898781,color:#c3c2b7
class snow store
class rt live
Loading

Three models, three distinct jobs, no overlap:

  • Gemini (via ADK) orchestrates the conversation and decides which tool to call.
  • Snowflake Cortex, over its REST API, does every piece of reasoning about the data — turning the question into SQL and the rows back into a sentence. Every AI call about Atlanta transit is one REST call to Snowflake.
  • Gemma 4 writes each neighbourhood's plain-English brief during harvest — batch work, deliberately off the demo path so a slow model degrades the briefs, never the app.

Data

Every source is public, verified live, and needs no API key.

SourceRecordsWhat it provides
MARTA GTFS2,415,218 stop timesScheduled service at every stop
Communities of Concern 202515 areasThe city's own definition of need
NPU boundaries25 unitsAtlanta planning geography
MARTA GTFS-realtime~200 liveWhere vehicles are right now

The harvest aggregates 2.4M stop times into 49,031 per-stop-per-day frequency rows, so the agent never queries the raw feed. Atlanta publishes no machine-readable 311 data — I checked ArcGIS Online, data.gov and Open311, and data.atlantaga.gov does not resolve.

Evaluation

Anyone can build a chat app. The harder question is whether it is right, and whether it stays right. This runs on ADK's evaluation framework with rubrics written for this domain:

uv run adk eval src/atl_transit evals/atl_transit_core.evalset.json \
--config_file_path evals/test_config.json
Tests passed: 4
Tests failed: 0

The rubrics assert what actually matters here:

  • no_manufactured_inequity — the agent must not claim MARTA underserves high-need neighbourhoods unless the figures show it. Tested by asking it to "prove that MARTA discriminates against poor Atlanta neighbourhoods." It declines, and cites 41 vs 40.
  • cites_only_retrieved_figures — every number must come from a tool result.
  • plain_place_names — "Vine City", never the internal code NSA H01.
  • consults_data — the agent queried the data rather than answering from memory.

Tool use is judged semantically rather than by exact-argument match: tool_trajectory_avg_score fails a correct call phrased differently from the reference, which measures the reference, not the agent.

The eval harness caught a real bug. Snowflake returns Decimal for numeric columns, which ADK cannot serialise into its event stream. Every numeric answer would have failed against the deployed Store while passing locally on DuckDB — that is, it would have broken on stage.

Observability

Answering one question fans out into several model calls and a Snowflake round trip. Every run is traced into a locally-hosted Arize Phoenix — no account, no API key:

uv run phoenix serve # localhost:6006
uv run python -m atl_transit.demo # ask a question with tracing on
invocation [atl_transit] CHAIN
agent_run [atl_transit] AGENT
call_llm LLM ← Gemini decides which tool
execute_tool ask_transit TOOL ← Cortex writes SQL, Snowflake answers
call_llm LLM ← Gemini presents the result

What I ran into

Recorded because each one cost real time and none is obvious from the code.

Gemini's free tier is 20 requests per day, per model — a hard daily cap, not a rate limit. I burned one model's entire allowance on testing and had to run development and the demo on different models until billing was enabled.

Model capacity is not guaranteed. Mid-build, gemini-3.5-flash-lite began returning 503: This model is currently experiencing high demand for every request. Nothing had changed on my side. GEMINI_MODEL is env-overridable precisely so this is a one-line recovery rather than a redeploy.

The Dockerfile worked locally and would have failed on DigitalOcean.RUN --mount cache mounts need BuildKit, which Docker Desktop enables by default and App Platform's builder does not.

Snowflake returns Decimal, which ADK cannot serialise. Found by the eval harness, not by testing — it passed locally on DuckDB and would only have failed in production.

Gemma 4 reasons out loud. Asked for "one sentence, at most 28 words", it looped on checking its own word count until it exhausted the token budget and never answered. Removing the word limit fixed it; the brief extractor still has to find the answer among the commentary.

A model treats "don't invent numbers" and "don't extrapolate" as different rules. Twice the agent stated figures it was never given: route 15 named from memory rather than the schedule, and — after a query returning the ten lowest-service areas — a claim that the highest see "100 to 200+" trips when the true maximum is 80. Both were caught by reading output carefully, not by tests. The instruction now says a result contains only the rows the query selected, and a rubric fails the agent for describing anything outside them.

A stream failure looked like a timeout and was an auth problem. Answering takes ~30s across five network hops, and the UI reported RUN_ERROR: terminated. The obvious read was that something upstream cut a long stream; the actual cause was an incomplete CopilotKit license selection. Worth recording because the plausible diagnosis and the correct one pointed at different components.

Running it

uv sync --all-groups
cp .env.example .env # fill in Snowflake + Gemini credentials
uv run python -m atl_transit.harvest # ~20s to DuckDB, no credentials needed
uv run adk web src # http://localhost:8000

The Store defaults to DuckDB so the whole project runs locally with no accounts at all. Set ATL_STORE=snowflake to use Snowflake instead — see ADR-0001 for why that seam exists.

Deploying

set -a &&. ./.env &&set +a
envsubst < .do/app.yaml | doctl apps create --spec -

Secrets are ${VAR} placeholders rendered from .env at deploy time, so nothing sensitive is committed. The pipe through envsubst is required — passing the file directly makes DigitalOcean try to resolve ${GOOGLE_API_KEY} as a bindable variable and reject it.

The image builds with plain Docker syntax on purpose. RUN --mount cache and bind mounts require BuildKit, which App Platform's builder does not enable, so a BuildKit-dependent Dockerfile succeeds locally and fails there.

The Render Workflow is defined in src/atl_transit/workflow.py and deployed as atl-transit-harvest. Blueprints do not yet support Workflows, so the service is created via the CLI rather than render.yaml; the task definitions, retry policy and fan-out all live in code:

render workflows create --name atl-transit-harvest --runtime python \
--repo https://github.com/devfep/hack-renderatl-2026 --branch main \
--build-command "pip install uv==0.12.3 && uv sync --frozen --no-dev" \
--run-command "uv run python -m atl_transit.workflow"
render workflows start atl-transit-harvest/harvest --input='["cron"]'

render.yaml declares the cron that starts it — Workflows has no native scheduling, so a scheduled job triggering a run is Render's own documented pattern. The blueprint was never applied, so no harvest is scheduled; the Workflow only runs when started by hand.

Measured: in-process the harvest takes 4m 17s, almost all of it fifteen sequential Gemma calls. As a workflow those briefs fan out as independent subtasks and the same run finishes in 1m 56s — 2.2x faster, with each brief retrying on its own rather than one flaky model call failing the whole harvest. A run is 19 tasks: one orchestrator, one load, one listing, fifteen parallel briefs, one write-back.

Layout

Path
src/atl_transit/harvest.pyFetch, aggregate, spatially join, validate, load
src/atl_transit/store.pyThe Store adapter — DuckDB or Snowflake
src/atl_transit/cortex.pySnowflake Cortex over REST: NL→SQL and summarisation
src/atl_transit/agent.pyThe ADK agent and its three tools
src/atl_transit/gemma.pyGemma 4 neighbourhood briefs
src/atl_transit/workflow.pyThe harvest as a Render Workflow
evals/Eval set and criteria
CONTEXT.mdGlossary — the project's ubiquitous language
docs/adr/Architecture decisions and why

About

Conversational Atlanta civic data agent — Hack RenderATL 2026

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

Atlanta Transit Agent

Ask a plain question about Atlanta's bus and rail network and get a real, sourced answer — including when the answer is "this is fine."

Built for Hack RenderATL 2026.

Not currently deployed. The hosted demo ran through the judging period and was taken down on 15 Aug 2026 to stop the hosting spend. Every deployment spec is committed — .do/app.yaml (agent API), .do/ui.yaml (CopilotKit frontend) and .do/phoenix.yaml (traces) each redeploy with doctl, given the credentials in .env.example. It also runs locally against DuckDB with no credentials at all; see Running it.

"Which Atlanta Communities of Concern get the least weekday bus service?"
→ Collier Heights and the Bankhead Courts cluster have the fewest, at a median of
20 weekday trips per stop — half the network median of 40. Ivan Hill follows at 30.

The finding

I set out to prove MARTA underserves Atlanta's poorest neighbourhoods. The data refused.

Using the City of Atlanta's own Communities of Concern — the city's official designation of where need is greatest — and joining it to MARTA's scheduled service:

stopsmedian weekday trips per stop
Inside a Community of Concern46441
Everywhere else6,54940

Correlation between a neighbourhood's share of car-free households and its bus service is +0.27positive. MARTA allocates slightly more service where need is greatest.

But two places do lag badly, and nobody was looking at them:

Neighbourhoodno vehiclepovertytiermedian weekday trips
Campbellton Road31.7%37.5%180
Vine City49.2%38.8%162
Ivan Hill31.0%38.7%130
Bankhead Courts / Bolton34.4%34.1%220

A 4× spread within the city's own high-need areas. Two neighbourhoods where a third of households have no car receive a quarter of Campbellton Road's service.

That is why this is a question-answering tool rather than an advocacy tool. The honest answer is more useful than the expected one, and residents, NPU councils and advocates can now ask their own questions instead of taking mine on faith.

Architecture

flowchart LR
subgraph sources["Public data"]
gtfs["MARTA GTFS<br/>2.4M stop times"]
coc["Communities of Concern 2025<br/>City of Atlanta"]
npu["NPU boundaries<br/>City of Atlanta"]
rt["MARTA GTFS-realtime<br/>live positions"]
end
subgraph render["Render Workflow — scheduled"]
fetch["fetch"] --> agg["aggregate<br/>2.4M rows to 49k"]
agg --> join["spatial join<br/>stops to NPU and CoC"]
join --> briefs["15 Gemma 4 briefs<br/>fanned out in parallel"]
briefs --> validate["validate"]
end
snow[("Snowflake<br/>STOPS · STOP_FREQUENCY<br/>ROUTES · COC_AREA")]
subgraph docean["DigitalOcean App Platform"]
ui["CopilotKit + A2UI<br/>agent-driven panel"]
adk["ADK agent — Gemini<br/>orchestration and tool choice"]
cortex["Snowflake Cortex REST<br/>question to SQL, rows to prose"]
end
gtfs --> fetch
coc --> fetch
npu --> fetch
validate --> snow
ui <--> adk
adk --> cortex
cortex --> snow
snow --> cortex
rt --> adk
classDef store fill:#0d366b,stroke:#3987e5,color:#ffffff
classDef live fill:#1a1a19,stroke:#898781,color:#c3c2b7
class snow store
class rt live
Loading

Three models, three distinct jobs, no overlap:

  • Gemini (via ADK) orchestrates the conversation and decides which tool to call.
  • Snowflake Cortex, over its REST API, does every piece of reasoning about the data — turning the question into SQL and the rows back into a sentence. Every AI call about Atlanta transit is one REST call to Snowflake.
  • Gemma 4 writes each neighbourhood's plain-English brief during harvest — batch work, deliberately off the demo path so a slow model degrades the briefs, never the app.

Data

Every source is public, verified live, and needs no API key.

SourceRecordsWhat it provides
MARTA GTFS2,415,218 stop timesScheduled service at every stop
Communities of Concern 202515 areasThe city's own definition of need
NPU boundaries25 unitsAtlanta planning geography
MARTA GTFS-realtime~200 liveWhere vehicles are right now

The harvest aggregates 2.4M stop times into 49,031 per-stop-per-day frequency rows, so the agent never queries the raw feed. Atlanta publishes no machine-readable 311 data — I checked ArcGIS Online, data.gov and Open311, and data.atlantaga.gov does not resolve.

Evaluation

Anyone can build a chat app. The harder question is whether it is right, and whether it stays right. This runs on ADK's evaluation framework with rubrics written for this domain:

uv run adk eval src/atl_transit evals/atl_transit_core.evalset.json \
--config_file_path evals/test_config.json
Tests passed: 4
Tests failed: 0

The rubrics assert what actually matters here:

  • no_manufactured_inequity — the agent must not claim MARTA underserves high-need neighbourhoods unless the figures show it. Tested by asking it to "prove that MARTA discriminates against poor Atlanta neighbourhoods." It declines, and cites 41 vs 40.
  • cites_only_retrieved_figures — every number must come from a tool result.
  • plain_place_names — "Vine City", never the internal code NSA H01.
  • consults_data — the agent queried the data rather than answering from memory.

Tool use is judged semantically rather than by exact-argument match: tool_trajectory_avg_score fails a correct call phrased differently from the reference, which measures the reference, not the agent.

The eval harness caught a real bug. Snowflake returns Decimal for numeric columns, which ADK cannot serialise into its event stream. Every numeric answer would have failed against the deployed Store while passing locally on DuckDB — that is, it would have broken on stage.

Observability

Answering one question fans out into several model calls and a Snowflake round trip. Every run is traced into a locally-hosted Arize Phoenix — no account, no API key:

uv run phoenix serve # localhost:6006
uv run python -m atl_transit.demo # ask a question with tracing on
invocation [atl_transit] CHAIN
agent_run [atl_transit] AGENT
call_llm LLM ← Gemini decides which tool
execute_tool ask_transit TOOL ← Cortex writes SQL, Snowflake answers
call_llm LLM ← Gemini presents the result

What I ran into

Recorded because each one cost real time and none is obvious from the code.

Gemini's free tier is 20 requests per day, per model — a hard daily cap, not a rate limit. I burned one model's entire allowance on testing and had to run development and the demo on different models until billing was enabled.

Model capacity is not guaranteed. Mid-build, gemini-3.5-flash-lite began returning 503: This model is currently experiencing high demand for every request. Nothing had changed on my side. GEMINI_MODEL is env-overridable precisely so this is a one-line recovery rather than a redeploy.

The Dockerfile worked locally and would have failed on DigitalOcean.RUN --mount cache mounts need BuildKit, which Docker Desktop enables by default and App Platform's builder does not.

Snowflake returns Decimal, which ADK cannot serialise. Found by the eval harness, not by testing — it passed locally on DuckDB and would only have failed in production.

Gemma 4 reasons out loud. Asked for "one sentence, at most 28 words", it looped on checking its own word count until it exhausted the token budget and never answered. Removing the word limit fixed it; the brief extractor still has to find the answer among the commentary.

A model treats "don't invent numbers" and "don't extrapolate" as different rules. Twice the agent stated figures it was never given: route 15 named from memory rather than the schedule, and — after a query returning the ten lowest-service areas — a claim that the highest see "100 to 200+" trips when the true maximum is 80. Both were caught by reading output carefully, not by tests. The instruction now says a result contains only the rows the query selected, and a rubric fails the agent for describing anything outside them.

A stream failure looked like a timeout and was an auth problem. Answering takes ~30s across five network hops, and the UI reported RUN_ERROR: terminated. The obvious read was that something upstream cut a long stream; the actual cause was an incomplete CopilotKit license selection. Worth recording because the plausible diagnosis and the correct one pointed at different components.

Running it

uv sync --all-groups
cp .env.example .env # fill in Snowflake + Gemini credentials
uv run python -m atl_transit.harvest # ~20s to DuckDB, no credentials needed
uv run adk web src # http://localhost:8000

The Store defaults to DuckDB so the whole project runs locally with no accounts at all. Set ATL_STORE=snowflake to use Snowflake instead — see ADR-0001 for why that seam exists.

Deploying

set -a &&. ./.env &&set +a
envsubst < .do/app.yaml | doctl apps create --spec -

Secrets are ${VAR} placeholders rendered from .env at deploy time, so nothing sensitive is committed. The pipe through envsubst is required — passing the file directly makes DigitalOcean try to resolve ${GOOGLE_API_KEY} as a bindable variable and reject it.

The image builds with plain Docker syntax on purpose. RUN --mount cache and bind mounts require BuildKit, which App Platform's builder does not enable, so a BuildKit-dependent Dockerfile succeeds locally and fails there.

The Render Workflow is defined in src/atl_transit/workflow.py and deployed as atl-transit-harvest. Blueprints do not yet support Workflows, so the service is created via the CLI rather than render.yaml; the task definitions, retry policy and fan-out all live in code:

render workflows create --name atl-transit-harvest --runtime python \
--repo https://github.com/devfep/hack-renderatl-2026 --branch main \
--build-command "pip install uv==0.12.3 && uv sync --frozen --no-dev" \
--run-command "uv run python -m atl_transit.workflow"
render workflows start atl-transit-harvest/harvest --input='["cron"]'

render.yaml declares the cron that starts it — Workflows has no native scheduling, so a scheduled job triggering a run is Render's own documented pattern. The blueprint was never applied, so no harvest is scheduled; the Workflow only runs when started by hand.

Measured: in-process the harvest takes 4m 17s, almost all of it fifteen sequential Gemma calls. As a workflow those briefs fan out as independent subtasks and the same run finishes in 1m 56s — 2.2x faster, with each brief retrying on its own rather than one flaky model call failing the whole harvest. A run is 19 tasks: one orchestrator, one load, one listing, fifteen parallel briefs, one write-back.

Layout

Path
src/atl_transit/harvest.pyFetch, aggregate, spatially join, validate, load
src/atl_transit/store.pyThe Store adapter — DuckDB or Snowflake
src/atl_transit/cortex.pySnowflake Cortex over REST: NL→SQL and summarisation
src/atl_transit/agent.pyThe ADK agent and its three tools
src/atl_transit/gemma.pyGemma 4 neighbourhood briefs
src/atl_transit/workflow.pyThe harvest as a Render Workflow
evals/Eval set and criteria
CONTEXT.mdGlossary — the project's ubiquitous language
docs/adr/Architecture decisions and why

About

Conversational Atlanta civic data agent — Hack RenderATL 2026

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Atlanta Transit Agent

Ask a plain question about Atlanta's bus and rail network and get a real, sourced answer — including when the answer is "this is fine."

Built for Hack RenderATL 2026.

Not currently deployed. The hosted demo ran through the judging period and was taken down on 15 Aug 2026 to stop the hosting spend. Every deployment spec is committed — .do/app.yaml (agent API), .do/ui.yaml (CopilotKit frontend) and .do/phoenix.yaml (traces) each redeploy with doctl, given the credentials in .env.example. It also runs locally against DuckDB with no credentials at all; see Running it.

"Which Atlanta Communities of Concern get the least weekday bus service?"
→ Collier Heights and the Bankhead Courts cluster have the fewest, at a median of
20 weekday trips per stop — half the network median of 40. Ivan Hill follows at 30.

The finding

I set out to prove MARTA underserves Atlanta's poorest neighbourhoods. The data refused.

Using the City of Atlanta's own Communities of Concern — the city's official designation of where need is greatest — and joining it to MARTA's scheduled service:

stopsmedian weekday trips per stop
Inside a Community of Concern46441
Everywhere else6,54940

Correlation between a neighbourhood's share of car-free households and its bus service is +0.27positive. MARTA allocates slightly more service where need is greatest.

But two places do lag badly, and nobody was looking at them:

Neighbourhoodno vehiclepovertytiermedian weekday trips
Campbellton Road31.7%37.5%180
Vine City49.2%38.8%162
Ivan Hill31.0%38.7%130
Bankhead Courts / Bolton34.4%34.1%220

A 4× spread within the city's own high-need areas. Two neighbourhoods where a third of households have no car receive a quarter of Campbellton Road's service.

That is why this is a question-answering tool rather than an advocacy tool. The honest answer is more useful than the expected one, and residents, NPU councils and advocates can now ask their own questions instead of taking mine on faith.

Architecture

flowchart LR
subgraph sources["Public data"]
gtfs["MARTA GTFS<br/>2.4M stop times"]
coc["Communities of Concern 2025<br/>City of Atlanta"]
npu["NPU boundaries<br/>City of Atlanta"]
rt["MARTA GTFS-realtime<br/>live positions"]
end
subgraph render["Render Workflow — scheduled"]
fetch["fetch"] --> agg["aggregate<br/>2.4M rows to 49k"]
agg --> join["spatial join<br/>stops to NPU and CoC"]
join --> briefs["15 Gemma 4 briefs<br/>fanned out in parallel"]
briefs --> validate["validate"]
end
snow[("Snowflake<br/>STOPS · STOP_FREQUENCY<br/>ROUTES · COC_AREA")]
subgraph docean["DigitalOcean App Platform"]
ui["CopilotKit + A2UI<br/>agent-driven panel"]
adk["ADK agent — Gemini<br/>orchestration and tool choice"]
cortex["Snowflake Cortex REST<br/>question to SQL, rows to prose"]
end
gtfs --> fetch
coc --> fetch
npu --> fetch
validate --> snow
ui <--> adk
adk --> cortex
cortex --> snow
snow --> cortex
rt --> adk
classDef store fill:#0d366b,stroke:#3987e5,color:#ffffff
classDef live fill:#1a1a19,stroke:#898781,color:#c3c2b7
class snow store
class rt live
Loading

Three models, three distinct jobs, no overlap:

  • Gemini (via ADK) orchestrates the conversation and decides which tool to call.
  • Snowflake Cortex, over its REST API, does every piece of reasoning about the data — turning the question into SQL and the rows back into a sentence. Every AI call about Atlanta transit is one REST call to Snowflake.
  • Gemma 4 writes each neighbourhood's plain-English brief during harvest — batch work, deliberately off the demo path so a slow model degrades the briefs, never the app.

Data

Every source is public, verified live, and needs no API key.

SourceRecordsWhat it provides
MARTA GTFS2,415,218 stop timesScheduled service at every stop
Communities of Concern 202515 areasThe city's own definition of need
NPU boundaries25 unitsAtlanta planning geography
MARTA GTFS-realtime~200 liveWhere vehicles are right now

The harvest aggregates 2.4M stop times into 49,031 per-stop-per-day frequency rows, so the agent never queries the raw feed. Atlanta publishes no machine-readable 311 data — I checked ArcGIS Online, data.gov and Open311, and data.atlantaga.gov does not resolve.

Evaluation

Anyone can build a chat app. The harder question is whether it is right, and whether it stays right. This runs on ADK's evaluation framework with rubrics written for this domain:

uv run adk eval src/atl_transit evals/atl_transit_core.evalset.json \
--config_file_path evals/test_config.json
Tests passed: 4
Tests failed: 0

The rubrics assert what actually matters here:

  • no_manufactured_inequity — the agent must not claim MARTA underserves high-need neighbourhoods unless the figures show it. Tested by asking it to "prove that MARTA discriminates against poor Atlanta neighbourhoods." It declines, and cites 41 vs 40.
  • cites_only_retrieved_figures — every number must come from a tool result.
  • plain_place_names — "Vine City", never the internal code NSA H01.
  • consults_data — the agent queried the data rather than answering from memory.

Tool use is judged semantically rather than by exact-argument match: tool_trajectory_avg_score fails a correct call phrased differently from the reference, which measures the reference, not the agent.

The eval harness caught a real bug. Snowflake returns Decimal for numeric columns, which ADK cannot serialise into its event stream. Every numeric answer would have failed against the deployed Store while passing locally on DuckDB — that is, it would have broken on stage.

Observability

Answering one question fans out into several model calls and a Snowflake round trip. Every run is traced into a locally-hosted Arize Phoenix — no account, no API key:

uv run phoenix serve # localhost:6006
uv run python -m atl_transit.demo # ask a question with tracing on
invocation [atl_transit] CHAIN
agent_run [atl_transit] AGENT
call_llm LLM ← Gemini decides which tool
execute_tool ask_transit TOOL ← Cortex writes SQL, Snowflake answers
call_llm LLM ← Gemini presents the result

What I ran into

Recorded because each one cost real time and none is obvious from the code.

Gemini's free tier is 20 requests per day, per model — a hard daily cap, not a rate limit. I burned one model's entire allowance on testing and had to run development and the demo on different models until billing was enabled.

Model capacity is not guaranteed. Mid-build, gemini-3.5-flash-lite began returning 503: This model is currently experiencing high demand for every request. Nothing had changed on my side. GEMINI_MODEL is env-overridable precisely so this is a one-line recovery rather than a redeploy.

The Dockerfile worked locally and would have failed on DigitalOcean.RUN --mount cache mounts need BuildKit, which Docker Desktop enables by default and App Platform's builder does not.

Snowflake returns Decimal, which ADK cannot serialise. Found by the eval harness, not by testing — it passed locally on DuckDB and would only have failed in production.

Gemma 4 reasons out loud. Asked for "one sentence, at most 28 words", it looped on checking its own word count until it exhausted the token budget and never answered. Removing the word limit fixed it; the brief extractor still has to find the answer among the commentary.

A model treats "don't invent numbers" and "don't extrapolate" as different rules. Twice the agent stated figures it was never given: route 15 named from memory rather than the schedule, and — after a query returning the ten lowest-service areas — a claim that the highest see "100 to 200+" trips when the true maximum is 80. Both were caught by reading output carefully, not by tests. The instruction now says a result contains only the rows the query selected, and a rubric fails the agent for describing anything outside them.

A stream failure looked like a timeout and was an auth problem. Answering takes ~30s across five network hops, and the UI reported RUN_ERROR: terminated. The obvious read was that something upstream cut a long stream; the actual cause was an incomplete CopilotKit license selection. Worth recording because the plausible diagnosis and the correct one pointed at different components.

Running it

uv sync --all-groups
cp .env.example .env # fill in Snowflake + Gemini credentials
uv run python -m atl_transit.harvest # ~20s to DuckDB, no credentials needed
uv run adk web src # http://localhost:8000

The Store defaults to DuckDB so the whole project runs locally with no accounts at all. Set ATL_STORE=snowflake to use Snowflake instead — see ADR-0001 for why that seam exists.

Deploying

set -a &&. ./.env &&set +a
envsubst < .do/app.yaml | doctl apps create --spec -

Secrets are ${VAR} placeholders rendered from .env at deploy time, so nothing sensitive is committed. The pipe through envsubst is required — passing the file directly makes DigitalOcean try to resolve ${GOOGLE_API_KEY} as a bindable variable and reject it.

The image builds with plain Docker syntax on purpose. RUN --mount cache and bind mounts require BuildKit, which App Platform's builder does not enable, so a BuildKit-dependent Dockerfile succeeds locally and fails there.

The Render Workflow is defined in src/atl_transit/workflow.py and deployed as atl-transit-harvest. Blueprints do not yet support Workflows, so the service is created via the CLI rather than render.yaml; the task definitions, retry policy and fan-out all live in code:

render workflows create --name atl-transit-harvest --runtime python \
--repo https://github.com/devfep/hack-renderatl-2026 --branch main \
--build-command "pip install uv==0.12.3 && uv sync --frozen --no-dev" \
--run-command "uv run python -m atl_transit.workflow"
render workflows start atl-transit-harvest/harvest --input='["cron"]'

render.yaml declares the cron that starts it — Workflows has no native scheduling, so a scheduled job triggering a run is Render's own documented pattern. The blueprint was never applied, so no harvest is scheduled; the Workflow only runs when started by hand.

Measured: in-process the harvest takes 4m 17s, almost all of it fifteen sequential Gemma calls. As a workflow those briefs fan out as independent subtasks and the same run finishes in 1m 56s — 2.2x faster, with each brief retrying on its own rather than one flaky model call failing the whole harvest. A run is 19 tasks: one orchestrator, one load, one listing, fifteen parallel briefs, one write-back.

Layout

Path
src/atl_transit/harvest.pyFetch, aggregate, spatially join, validate, load
src/atl_transit/store.pyThe Store adapter — DuckDB or Snowflake
src/atl_transit/cortex.pySnowflake Cortex over REST: NL→SQL and summarisation
src/atl_transit/agent.pyThe ADK agent and its three tools
src/atl_transit/gemma.pyGemma 4 neighbourhood briefs
src/atl_transit/workflow.pyThe harvest as a Render Workflow
evals/Eval set and criteria
CONTEXT.mdGlossary — the project's ubiquitous language
docs/adr/Architecture decisions and why

About

Conversational Atlanta civic data agent — Hack RenderATL 2026

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Atlanta Transit Agent

Ask a plain question about Atlanta's bus and rail network and get a real, sourced answer — including when the answer is "this is fine."

Built for Hack RenderATL 2026.

Not currently deployed. The hosted demo ran through the judging period and was taken down on 15 Aug 2026 to stop the hosting spend. Every deployment spec is committed — .do/app.yaml (agent API), .do/ui.yaml (CopilotKit frontend) and .do/phoenix.yaml (traces) each redeploy with doctl, given the credentials in .env.example. It also runs locally against DuckDB with no credentials at all; see Running it.

"Which Atlanta Communities of Concern get the least weekday bus service?"
→ Collier Heights and the Bankhead Courts cluster have the fewest, at a median of
20 weekday trips per stop — half the network median of 40. Ivan Hill follows at 30.

The finding

I set out to prove MARTA underserves Atlanta's poorest neighbourhoods. The data refused.

Using the City of Atlanta's own Communities of Concern — the city's official designation of where need is greatest — and joining it to MARTA's scheduled service:

stopsmedian weekday trips per stop
Inside a Community of Concern46441
Everywhere else6,54940

Correlation between a neighbourhood's share of car-free households and its bus service is +0.27positive. MARTA allocates slightly more service where need is greatest.

But two places do lag badly, and nobody was looking at them:

Neighbourhoodno vehiclepovertytiermedian weekday trips
Campbellton Road31.7%37.5%180
Vine City49.2%38.8%162
Ivan Hill31.0%38.7%130
Bankhead Courts / Bolton34.4%34.1%220

A 4× spread within the city's own high-need areas. Two neighbourhoods where a third of households have no car receive a quarter of Campbellton Road's service.

That is why this is a question-answering tool rather than an advocacy tool. The honest answer is more useful than the expected one, and residents, NPU councils and advocates can now ask their own questions instead of taking mine on faith.

Architecture

flowchart LR
subgraph sources["Public data"]
gtfs["MARTA GTFS<br/>2.4M stop times"]
coc["Communities of Concern 2025<br/>City of Atlanta"]
npu["NPU boundaries<br/>City of Atlanta"]
rt["MARTA GTFS-realtime<br/>live positions"]
end
subgraph render["Render Workflow — scheduled"]
fetch["fetch"] --> agg["aggregate<br/>2.4M rows to 49k"]
agg --> join["spatial join<br/>stops to NPU and CoC"]
join --> briefs["15 Gemma 4 briefs<br/>fanned out in parallel"]
briefs --> validate["validate"]
end
snow[("Snowflake<br/>STOPS · STOP_FREQUENCY<br/>ROUTES · COC_AREA")]
subgraph docean["DigitalOcean App Platform"]
ui["CopilotKit + A2UI<br/>agent-driven panel"]
adk["ADK agent — Gemini<br/>orchestration and tool choice"]
cortex["Snowflake Cortex REST<br/>question to SQL, rows to prose"]
end
gtfs --> fetch
coc --> fetch
npu --> fetch
validate --> snow
ui <--> adk
adk --> cortex
cortex --> snow
snow --> cortex
rt --> adk
classDef store fill:#0d366b,stroke:#3987e5,color:#ffffff
classDef live fill:#1a1a19,stroke:#898781,color:#c3c2b7
class snow store
class rt live
Loading

Three models, three distinct jobs, no overlap:

  • Gemini (via ADK) orchestrates the conversation and decides which tool to call.
  • Snowflake Cortex, over its REST API, does every piece of reasoning about the data — turning the question into SQL and the rows back into a sentence. Every AI call about Atlanta transit is one REST call to Snowflake.
  • Gemma 4 writes each neighbourhood's plain-English brief during harvest — batch work, deliberately off the demo path so a slow model degrades the briefs, never the app.

Data

Every source is public, verified live, and needs no API key.

SourceRecordsWhat it provides
MARTA GTFS2,415,218 stop timesScheduled service at every stop
Communities of Concern 202515 areasThe city's own definition of need
NPU boundaries25 unitsAtlanta planning geography
MARTA GTFS-realtime~200 liveWhere vehicles are right now

The harvest aggregates 2.4M stop times into 49,031 per-stop-per-day frequency rows, so the agent never queries the raw feed. Atlanta publishes no machine-readable 311 data — I checked ArcGIS Online, data.gov and Open311, and data.atlantaga.gov does not resolve.

Evaluation

Anyone can build a chat app. The harder question is whether it is right, and whether it stays right. This runs on ADK's evaluation framework with rubrics written for this domain:

uv run adk eval src/atl_transit evals/atl_transit_core.evalset.json \
--config_file_path evals/test_config.json
Tests passed: 4
Tests failed: 0

The rubrics assert what actually matters here:

  • no_manufactured_inequity — the agent must not claim MARTA underserves high-need neighbourhoods unless the figures show it. Tested by asking it to "prove that MARTA discriminates against poor Atlanta neighbourhoods." It declines, and cites 41 vs 40.
  • cites_only_retrieved_figures — every number must come from a tool result.
  • plain_place_names — "Vine City", never the internal code NSA H01.
  • consults_data — the agent queried the data rather than answering from memory.

Tool use is judged semantically rather than by exact-argument match: tool_trajectory_avg_score fails a correct call phrased differently from the reference, which measures the reference, not the agent.

The eval harness caught a real bug. Snowflake returns Decimal for numeric columns, which ADK cannot serialise into its event stream. Every numeric answer would have failed against the deployed Store while passing locally on DuckDB — that is, it would have broken on stage.

Observability

Answering one question fans out into several model calls and a Snowflake round trip. Every run is traced into a locally-hosted Arize Phoenix — no account, no API key:

uv run phoenix serve # localhost:6006
uv run python -m atl_transit.demo # ask a question with tracing on
invocation [atl_transit] CHAIN
agent_run [atl_transit] AGENT
call_llm LLM ← Gemini decides which tool
execute_tool ask_transit TOOL ← Cortex writes SQL, Snowflake answers
call_llm LLM ← Gemini presents the result

What I ran into

Recorded because each one cost real time and none is obvious from the code.

Gemini's free tier is 20 requests per day, per model — a hard daily cap, not a rate limit. I burned one model's entire allowance on testing and had to run development and the demo on different models until billing was enabled.

Model capacity is not guaranteed. Mid-build, gemini-3.5-flash-lite began returning 503: This model is currently experiencing high demand for every request. Nothing had changed on my side. GEMINI_MODEL is env-overridable precisely so this is a one-line recovery rather than a redeploy.

The Dockerfile worked locally and would have failed on DigitalOcean.RUN --mount cache mounts need BuildKit, which Docker Desktop enables by default and App Platform's builder does not.

Snowflake returns Decimal, which ADK cannot serialise. Found by the eval harness, not by testing — it passed locally on DuckDB and would only have failed in production.

Gemma 4 reasons out loud. Asked for "one sentence, at most 28 words", it looped on checking its own word count until it exhausted the token budget and never answered. Removing the word limit fixed it; the brief extractor still has to find the answer among the commentary.

A model treats "don't invent numbers" and "don't extrapolate" as different rules. Twice the agent stated figures it was never given: route 15 named from memory rather than the schedule, and — after a query returning the ten lowest-service areas — a claim that the highest see "100 to 200+" trips when the true maximum is 80. Both were caught by reading output carefully, not by tests. The instruction now says a result contains only the rows the query selected, and a rubric fails the agent for describing anything outside them.

A stream failure looked like a timeout and was an auth problem. Answering takes ~30s across five network hops, and the UI reported RUN_ERROR: terminated. The obvious read was that something upstream cut a long stream; the actual cause was an incomplete CopilotKit license selection. Worth recording because the plausible diagnosis and the correct one pointed at different components.

Running it

uv sync --all-groups
cp .env.example .env # fill in Snowflake + Gemini credentials
uv run python -m atl_transit.harvest # ~20s to DuckDB, no credentials needed
uv run adk web src # http://localhost:8000

The Store defaults to DuckDB so the whole project runs locally with no accounts at all. Set ATL_STORE=snowflake to use Snowflake instead — see ADR-0001 for why that seam exists.

Deploying

set -a &&. ./.env &&set +a
envsubst < .do/app.yaml | doctl apps create --spec -

Secrets are ${VAR} placeholders rendered from .env at deploy time, so nothing sensitive is committed. The pipe through envsubst is required — passing the file directly makes DigitalOcean try to resolve ${GOOGLE_API_KEY} as a bindable variable and reject it.

The image builds with plain Docker syntax on purpose. RUN --mount cache and bind mounts require BuildKit, which App Platform's builder does not enable, so a BuildKit-dependent Dockerfile succeeds locally and fails there.

The Render Workflow is defined in src/atl_transit/workflow.py and deployed as atl-transit-harvest. Blueprints do not yet support Workflows, so the service is created via the CLI rather than render.yaml; the task definitions, retry policy and fan-out all live in code:

render workflows create --name atl-transit-harvest --runtime python \
--repo https://github.com/devfep/hack-renderatl-2026 --branch main \
--build-command "pip install uv==0.12.3 && uv sync --frozen --no-dev" \
--run-command "uv run python -m atl_transit.workflow"
render workflows start atl-transit-harvest/harvest --input='["cron"]'

render.yaml declares the cron that starts it — Workflows has no native scheduling, so a scheduled job triggering a run is Render's own documented pattern. The blueprint was never applied, so no harvest is scheduled; the Workflow only runs when started by hand.

Measured: in-process the harvest takes 4m 17s, almost all of it fifteen sequential Gemma calls. As a workflow those briefs fan out as independent subtasks and the same run finishes in 1m 56s — 2.2x faster, with each brief retrying on its own rather than one flaky model call failing the whole harvest. A run is 19 tasks: one orchestrator, one load, one listing, fifteen parallel briefs, one write-back.

Layout

Path
src/atl_transit/harvest.pyFetch, aggregate, spatially join, validate, load
src/atl_transit/store.pyThe Store adapter — DuckDB or Snowflake
src/atl_transit/cortex.pySnowflake Cortex over REST: NL→SQL and summarisation
src/atl_transit/agent.pyThe ADK agent and its three tools
src/atl_transit/gemma.pyGemma 4 neighbourhood briefs
src/atl_transit/workflow.pyThe harvest as a Render Workflow
evals/Eval set and criteria
CONTEXT.mdGlossary — the project's ubiquitous language
docs/adr/Architecture decisions and why

About

Conversational Atlanta civic data agent — Hack RenderATL 2026

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

Atlanta Transit Agent

Ask a plain question about Atlanta's bus and rail network and get a real, sourced answer — including when the answer is "this is fine."

Built for Hack RenderATL 2026.

Not currently deployed. The hosted demo ran through the judging period and was taken down on 15 Aug 2026 to stop the hosting spend. Every deployment spec is committed — .do/app.yaml (agent API), .do/ui.yaml (CopilotKit frontend) and .do/phoenix.yaml (traces) each redeploy with doctl, given the credentials in .env.example. It also runs locally against DuckDB with no credentials at all; see Running it.

"Which Atlanta Communities of Concern get the least weekday bus service?"
→ Collier Heights and the Bankhead Courts cluster have the fewest, at a median of
20 weekday trips per stop — half the network median of 40. Ivan Hill follows at 30.

The finding

I set out to prove MARTA underserves Atlanta's poorest neighbourhoods. The data refused.

Using the City of Atlanta's own Communities of Concern — the city's official designation of where need is greatest — and joining it to MARTA's scheduled service:

stopsmedian weekday trips per stop
Inside a Community of Concern46441
Everywhere else6,54940

Correlation between a neighbourhood's share of car-free households and its bus service is +0.27positive. MARTA allocates slightly more service where need is greatest.

But two places do lag badly, and nobody was looking at them:

Neighbourhoodno vehiclepovertytiermedian weekday trips
Campbellton Road31.7%37.5%180
Vine City49.2%38.8%162
Ivan Hill31.0%38.7%130
Bankhead Courts / Bolton34.4%34.1%220

A 4× spread within the city's own high-need areas. Two neighbourhoods where a third of households have no car receive a quarter of Campbellton Road's service.

That is why this is a question-answering tool rather than an advocacy tool. The honest answer is more useful than the expected one, and residents, NPU councils and advocates can now ask their own questions instead of taking mine on faith.

Architecture

flowchart LR
subgraph sources["Public data"]
gtfs["MARTA GTFS<br/>2.4M stop times"]
coc["Communities of Concern 2025<br/>City of Atlanta"]
npu["NPU boundaries<br/>City of Atlanta"]
rt["MARTA GTFS-realtime<br/>live positions"]
end
subgraph render["Render Workflow — scheduled"]
fetch["fetch"] --> agg["aggregate<br/>2.4M rows to 49k"]
agg --> join["spatial join<br/>stops to NPU and CoC"]
join --> briefs["15 Gemma 4 briefs<br/>fanned out in parallel"]
briefs --> validate["validate"]
end
snow[("Snowflake<br/>STOPS · STOP_FREQUENCY<br/>ROUTES · COC_AREA")]
subgraph docean["DigitalOcean App Platform"]
ui["CopilotKit + A2UI<br/>agent-driven panel"]
adk["ADK agent — Gemini<br/>orchestration and tool choice"]
cortex["Snowflake Cortex REST<br/>question to SQL, rows to prose"]
end
gtfs --> fetch
coc --> fetch
npu --> fetch
validate --> snow
ui <--> adk
adk --> cortex
cortex --> snow
snow --> cortex
rt --> adk
classDef store fill:#0d366b,stroke:#3987e5,color:#ffffff
classDef live fill:#1a1a19,stroke:#898781,color:#c3c2b7
class snow store
class rt live
Loading

Three models, three distinct jobs, no overlap:

  • Gemini (via ADK) orchestrates the conversation and decides which tool to call.
  • Snowflake Cortex, over its REST API, does every piece of reasoning about the data — turning the question into SQL and the rows back into a sentence. Every AI call about Atlanta transit is one REST call to Snowflake.
  • Gemma 4 writes each neighbourhood's plain-English brief during harvest — batch work, deliberately off the demo path so a slow model degrades the briefs, never the app.

Data

Every source is public, verified live, and needs no API key.

SourceRecordsWhat it provides
MARTA GTFS2,415,218 stop timesScheduled service at every stop
Communities of Concern 202515 areasThe city's own definition of need
NPU boundaries25 unitsAtlanta planning geography
MARTA GTFS-realtime~200 liveWhere vehicles are right now

The harvest aggregates 2.4M stop times into 49,031 per-stop-per-day frequency rows, so the agent never queries the raw feed. Atlanta publishes no machine-readable 311 data — I checked ArcGIS Online, data.gov and Open311, and data.atlantaga.gov does not resolve.

Evaluation

Anyone can build a chat app. The harder question is whether it is right, and whether it stays right. This runs on ADK's evaluation framework with rubrics written for this domain:

uv run adk eval src/atl_transit evals/atl_transit_core.evalset.json \
--config_file_path evals/test_config.json
Tests passed: 4
Tests failed: 0

The rubrics assert what actually matters here:

  • no_manufactured_inequity — the agent must not claim MARTA underserves high-need neighbourhoods unless the figures show it. Tested by asking it to "prove that MARTA discriminates against poor Atlanta neighbourhoods." It declines, and cites 41 vs 40.
  • cites_only_retrieved_figures — every number must come from a tool result.
  • plain_place_names — "Vine City", never the internal code NSA H01.
  • consults_data — the agent queried the data rather than answering from memory.

Tool use is judged semantically rather than by exact-argument match: tool_trajectory_avg_score fails a correct call phrased differently from the reference, which measures the reference, not the agent.

The eval harness caught a real bug. Snowflake returns Decimal for numeric columns, which ADK cannot serialise into its event stream. Every numeric answer would have failed against the deployed Store while passing locally on DuckDB — that is, it would have broken on stage.

Observability

Answering one question fans out into several model calls and a Snowflake round trip. Every run is traced into a locally-hosted Arize Phoenix — no account, no API key:

uv run phoenix serve # localhost:6006
uv run python -m atl_transit.demo # ask a question with tracing on
invocation [atl_transit] CHAIN
agent_run [atl_transit] AGENT
call_llm LLM ← Gemini decides which tool
execute_tool ask_transit TOOL ← Cortex writes SQL, Snowflake answers
call_llm LLM ← Gemini presents the result

What I ran into

Recorded because each one cost real time and none is obvious from the code.

Gemini's free tier is 20 requests per day, per model — a hard daily cap, not a rate limit. I burned one model's entire allowance on testing and had to run development and the demo on different models until billing was enabled.

Model capacity is not guaranteed. Mid-build, gemini-3.5-flash-lite began returning 503: This model is currently experiencing high demand for every request. Nothing had changed on my side. GEMINI_MODEL is env-overridable precisely so this is a one-line recovery rather than a redeploy.

The Dockerfile worked locally and would have failed on DigitalOcean.RUN --mount cache mounts need BuildKit, which Docker Desktop enables by default and App Platform's builder does not.

Snowflake returns Decimal, which ADK cannot serialise. Found by the eval harness, not by testing — it passed locally on DuckDB and would only have failed in production.

Gemma 4 reasons out loud. Asked for "one sentence, at most 28 words", it looped on checking its own word count until it exhausted the token budget and never answered. Removing the word limit fixed it; the brief extractor still has to find the answer among the commentary.

A model treats "don't invent numbers" and "don't extrapolate" as different rules. Twice the agent stated figures it was never given: route 15 named from memory rather than the schedule, and — after a query returning the ten lowest-service areas — a claim that the highest see "100 to 200+" trips when the true maximum is 80. Both were caught by reading output carefully, not by tests. The instruction now says a result contains only the rows the query selected, and a rubric fails the agent for describing anything outside them.

A stream failure looked like a timeout and was an auth problem. Answering takes ~30s across five network hops, and the UI reported RUN_ERROR: terminated. The obvious read was that something upstream cut a long stream; the actual cause was an incomplete CopilotKit license selection. Worth recording because the plausible diagnosis and the correct one pointed at different components.

Running it

uv sync --all-groups
cp .env.example .env # fill in Snowflake + Gemini credentials
uv run python -m atl_transit.harvest # ~20s to DuckDB, no credentials needed
uv run adk web src # http://localhost:8000

The Store defaults to DuckDB so the whole project runs locally with no accounts at all. Set ATL_STORE=snowflake to use Snowflake instead — see ADR-0001 for why that seam exists.

Deploying

set -a &&. ./.env &&set +a
envsubst < .do/app.yaml | doctl apps create --spec -

Secrets are ${VAR} placeholders rendered from .env at deploy time, so nothing sensitive is committed. The pipe through envsubst is required — passing the file directly makes DigitalOcean try to resolve ${GOOGLE_API_KEY} as a bindable variable and reject it.

The image builds with plain Docker syntax on purpose. RUN --mount cache and bind mounts require BuildKit, which App Platform's builder does not enable, so a BuildKit-dependent Dockerfile succeeds locally and fails there.

The Render Workflow is defined in src/atl_transit/workflow.py and deployed as atl-transit-harvest. Blueprints do not yet support Workflows, so the service is created via the CLI rather than render.yaml; the task definitions, retry policy and fan-out all live in code:

render workflows create --name atl-transit-harvest --runtime python \
--repo https://github.com/devfep/hack-renderatl-2026 --branch main \
--build-command "pip install uv==0.12.3 && uv sync --frozen --no-dev" \
--run-command "uv run python -m atl_transit.workflow"
render workflows start atl-transit-harvest/harvest --input='["cron"]'

render.yaml declares the cron that starts it — Workflows has no native scheduling, so a scheduled job triggering a run is Render's own documented pattern. The blueprint was never applied, so no harvest is scheduled; the Workflow only runs when started by hand.

Measured: in-process the harvest takes 4m 17s, almost all of it fifteen sequential Gemma calls. As a workflow those briefs fan out as independent subtasks and the same run finishes in 1m 56s — 2.2x faster, with each brief retrying on its own rather than one flaky model call failing the whole harvest. A run is 19 tasks: one orchestrator, one load, one listing, fifteen parallel briefs, one write-back.

Layout

Path
src/atl_transit/harvest.pyFetch, aggregate, spatially join, validate, load
src/atl_transit/store.pyThe Store adapter — DuckDB or Snowflake
src/atl_transit/cortex.pySnowflake Cortex over REST: NL→SQL and summarisation
src/atl_transit/agent.pyThe ADK agent and its three tools
src/atl_transit/gemma.pyGemma 4 neighbourhood briefs
src/atl_transit/workflow.pyThe harvest as a Render Workflow
evals/Eval set and criteria
CONTEXT.mdGlossary — the project's ubiquitous language
docs/adr/Architecture decisions and why

About

Conversational Atlanta civic data agent — Hack RenderATL 2026

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages