Architecture
Where code runs, who may call it, what state lives where, and where a second model belongs.
Maintained. This describes how the system works now, and is expected to be true. When it and the code disagree, the code is right and this needs fixing. Last checked 30 August 2026.
Built as described, except where NEXT.md says otherwise and where a section says so itself. The model holds the vocabulary; the evolution plan is about what does not exist yet.
The four surfaces
Four, and the division that matters is not language. It is who is allowed to call it.
| Surface | Where | Called by | What is there |
|---|---|---|---|
| Tools | TypeScript, in the request | The model | The verbs. Eight, flat, and they do not grow as the registry grows. Computing is one of them, run_analysis |
| Protocol clients | TypeScript, server | Tools. Never the model | Catalogue search, direct cell reads, provenance minting |
The tgis module | Python, in the sandbox | Code the model writes, in run_analysis | load, zonal_sum, buffer and the other operations; layer and value to bring results back |
| Connectors | Python, in the sandbox | The module. Never the model | Landing bytes, sniffing formats, refusing to guess a narrowing |
Where the language line falls
Search and single-value reads are TypeScript. Landing data is Python.
SDMX, CKAN's API, Socrata and the World Bank are HTTP and JSON parsing; they never need rasterio or geopandas. STAC, COG, WorldPop, Overture, Parquet and NetCDF always do. Two sets of connectors with almost no overlap, not two copies of one.
The line falls on the sandbox boundary because the sandbox exists to hold data: anything that does not produce a layer does not need one. "Does answering require geometry" was the first version of this criterion and it was wrong — it would put HAPI permanently in TypeScript, and the corridor question needs HAPI as a layer. Geometry is the common reason for composing, not the criterion.
The invariant
The model chooses. Code mints.
Rows two and four are unreachable from the model deliberately, because they are
where a Value acquires its provenance. That is what makes present_figure
refusing a typed number a structural property rather than a convention: there is
no path by which the model holds a number that code did not construct.
It also settles a tempting shortcut. A direct cell read is an HTTP GET and a
lookup, and it would be easy to give the model an http_get tool and let it
read the JSON itself. That number would arrive with no source, no licence, no
retrieval timestamp and no evidence type — the substitution this system exists
to prevent, arriving through the front door.
The third row is the exception that proves the rule: the model does write code
that calls tgis directly. That is what the sandbox is for, and it is also why
every tgis function records to the manifest rather than returning a bare
number. Work outside the module shows to the planner as unsourced.
What the model may not type, in full
"The model never types a number" is the headline, and it is only two thirds of the rule as it turned out. Three things arrive on a figure card, and all three have to come from code:
| How it is prevented | |
|---|---|
| The number | present_figure takes a reference, not a value |
| The lineage | manifest.derive builds derived_from from named input layers |
| The publisher, licence and title | A derived provenance has them null by construction, and the display reads them off the sources underneath |
The third was reachable for a while. A run that could not land its own polygon
as a layer hand-wrote a provenance dict instead — including source_id: "derived" and licence: "mixed (WorldPop, ODbL, Not stated)" — and both
appeared on the figure card and in the answer's source list as though a publisher
had stated them. The model was not being careless; it was being accurate, and
the display had no way to show a mixed licence, so it wrote one. The lesson is
the general one for this system: where the honest thing is unreachable, the
model will reach for the dishonest one and mean well by it. tgis.layer exists
because of that, and the fix is a capability, not a prohibition.
How a source is found
find_sources does two jobs that scale differently, so it does them in two
stages. Question to concept is a small closed problem: a few dozen concept rows,
stable, cheap to re-embed. Concept to source is relational and grows with
countries × datasets.
Filter, then rank. A SQL predicate over country, temporal overlap and modality cuts the corpus before either ranking arm sees it. The source-level vector arm then ranks what survives, with BM25 beside it, and the two are fused; BM25 is what catches the proper-noun case, a planner who types "Anbessa" or "ODbL", because those are what a dense vector blurs. A country predicate bolted onto the ranking instead would have treated the symptom — the fusion weight still moves with corpus size.
The model writes the query; the tool does not. Until 30 August 2026 the
retrieval text was assembled from the call's own arguments —
concepts.join(", ") plus in ${place} — so what got embedded was
"vehicle_stock in Ethiopia", a string no planner said. Two failures follow
from that and neither is about corpus size.
The exact axes were stated twice. Country is already a predicate and concepts
are already resolved ids; restating them as fuzzy text cannot make a result more
about the place, it can only rank sources that mention it above sources that
measure what was asked for. Measured on the live registry: the only reachable
source tagged vehicle_stock fell off the end of an eight-row list behind
hotosm_eth_health_facilities, and ranked first once the place left the query
text. Six concepts, the same result each time.
And whether the place belongs in the query at all is a per-question judgement. A
question about the population of Addis Ababa is better answered by a source
about Addis; a question about vehicle stock in Ethiopia is better answered by a
global table, and naming the country demotes it. Nothing in the arguments
separates those, so a template picks one reading and applies it everywhere —
a choice living in the tool. find_sources takes a query the model writes,
and the assembly survives only as a fallback for callers with no planner's words:
the eval's ranker arm, harvest, and the checks.
That is why claimed_place is not a string. Containment is a PostGIS predicate
over the place spine, which is also what lets resolve_place answer a name
without fetching a boundary file.
What a result carries is what the next fetch needs. Each reachable source
comes back with its description, its guidance, its profile — what the source
is, one shape per layer, with nothing from the area last fetched — its observed
coverage, and its filters: what each filter key takes, declared per connector,
and the options the source itself published, read from earlier refused fetches.
resolve_place adds units_inside, the spine's units one level down. Both
exist so the first fetch is the right one; the model
says where each comes from.
The two paths
Path A never touches the sandbox: cell: true on a fetch reads one number for
one unit over one request, and a narrowing that reaches more than one row comes
back as a miss rather than a guess. Path B is where the sandbox earns its cost —
the corridor run composed population, two transit feeds, a road network and a
surface temperature raster over ten subcities, and four of its analysis calls
did work that nothing else could have done.
The sandbox boots with the run and stops on its own. createRunContext
starts it without waiting, so the boot runs beside the model's first turns
instead of inside the first fetch: 16 of 17 runs to 15 October fetched, and the
first fetch carried ~7 s of boot. A question answered from a direct read pays
for a sandbox it never uses. Its timeout is a lifetime from creation rather than
an idle timer, so the ten-minute window is topped up wherever a session is
actually touched: an hour of conversation keeps one machine and never
approaches it, and ten minutes after the last tool call it stops.
The network is off while model-written code runs
The sandbox swaps its network policy by phase, on the live machine, in about
0.3 s (POLICY in lib/sandbox/session.ts):
| Phase | Policy | What is running |
|---|---|---|
| Fetching | allow-all | Connector code we wrote |
| Analysis | deny-all | Model-written Python, reading local files |
The untrusted party is the model, not the connector. An allowlist of thirty
hosts would still leave generated code thirty places to send data; deny-all
leaves none, and it enforces "nothing enters except through fetch" rather than
asserting it. Why the allowlist was not built from the registry's endpoints is
under the sandbox.
Calls are not steps
MAX_STEPS counts model steps and the activity rail counts tool calls, so a run
showing 53 "steps" against a cap of 20 was never near the cap: parallel fetches
share a step. Only the parallel arms have slack. Sequential analysis calls
each cost a step, so rediscovery reaches the cap before retrieval does — which
is how a 28-call run once ended with no answer at all.
How a number becomes a figure
Path B in detail, because this is where most of a run's steps go and where every ergonomic mistake costs a round trip.
Three properties of that picture are load-bearing.
Minting is an act, not a return shape. tgis.value registers where it is
called. It used to be registered by the loop walking whatever the code returned,
one level deep — so code that returned its figures nested under a dict of
subcity names registered none of them, and got no error saying so. One run spent
five analyses discovering the rule and hit the step cap before writing an
answer; the run after it lost a step to the same wall; a third spent four. Now
the worker collects every tgis.value call on the analysis's own thread and
hands the ledger back, so returning a figure, nesting it, or leaving it in a
local variable all register the same figure.
Every registered figure carries a label. Ten figures from one call used to
come back as v1…v10 and nothing else, and the only way to say which was
which was the order they were minted in — which a run had to reason its way to,
and got wrong. The named operations label their own too, so an analysis that
calls zonal_stats ten times gets ten named ids beside whatever it minted
itself.
There is a way back in for anything. The named operations cover buffering,
zonal statistics and counting. They do not cover the set operations a coverage
question is made of — union these catchments, subtract them from that boundary —
and adding an operation per question shape is a losing race. So geometry stays
in the model's Python and tgis.layer brings the result back as a real layer.
Before it existed the only way out was to leave the system: hand-rolled
rasterio, hand-written provenance, everything unsourced by construction.
A disagreement names two figures. record_observation with kind: "conflict" takes two value_ids from the run and a why in the model's words.
Code reads both figures off the run's ledger — value, unit, sources, period —
and computes the ratio, so the conflict panel shows minted numbers beside the
explanation rather than numbers the model retyped. A conflict naming an id the
run never minted is refused with the reason.
It takes an array as well as a frame, landed on another raster's grid, so a
mask or a product of two rasters goes through zonal_sum like a fetched one.
tgis.load(id, like=…) puts one raster onto another's grid with nodata left
out of the resampling, and tgis.cell_area gives each cell's ground area. And
the inputs follow the code: tgis.value and tgis.layer rest on every layer
the analysis loaded unless told otherwise. A DeepSeek run on 15 October spent
most of its thinking on exactly these four — resampling a 30 m Mercator grid
onto a 100 m one, nodata, Mercator area, and how to give one figure three
sources — and never finished.
Ergonomics is architecture here
A round trip is thirty seconds of model time. Measured over four runs, the model spent more of its thinking on three questions about our API than on anything about cities:
| The question it kept asking | What answers it now |
|---|---|
| "What shape did this return?" | Results answer to a key or an attribute alike, and asking for something absent says which shape it is |
| "Did my figure register?" | registered comes back with labels; minting no longer depends on the return |
| "How do I get my own result back in?" | tgis.layer |
None of those is a GIS question. Each cost a round trip when it went wrong.
The middle band is the one to read. Closing rediscovery of the data — a
profile written by the fetch and returned by find_sources before the next one
— did not move the ratio at all. It moved which shape was being established,
from "what columns does this table have" to "what does our own code return, and
did my figure register?" Rediscovery is a property of every surface a run meets
for the first time, not of the data.
A fact belongs where it is consumed. A profile reaches the model in the tool
result that precedes the fetch, which is why it worked. A hundred and fifty
lines of prompt, or an API card, reach it once at the start of a run, which is
why neither was enough on its own. The card is in the prompt by default all the
same (TGIS_API_CARD=prompt): delivered after the first fetch, it arrived after
the turn in which a model plans the whole analysis, and a DeepSeek run planned
the flood question twice, once guessing at the module and once with the card. Nothing reaches the moment somebody writes
res.value on a dict except the error message, which is why the shapes now
explain themselves when asked for something they do not hold.
The run measured after that landed took 8 tool calls where the same question had taken 53, with no misses against 22, 7 model calls against 18, and $0.19 against $0.53. One run is not a measurement. The number to watch is the last bar, and the claim to be sceptical of is that ergonomics is finished rather than moved again.
The sandbox
| Layer | What |
|---|---|
| App | Next.js 16 on Vercel; the agent is AI SDK 7's ToolLoopAgent through the AI Gateway. GLM-5.3 Flash, the default, is pinned to zero-data-retention providers in a fixed order (GATEWAY_ROUTING in lib/models.ts) so consecutive calls hit one provider's prompt cache |
| Registry and record | Neon Postgres with PostGIS, pgvector and the Lakebase search extensions |
| Workspace | One Vercel Sandbox per conversation: Python 3.14, geopandas, rasterio, rasterstats, DuckDB |
| Bytes | Vercel Blob: the artefact cache and the raw upstream cache, public, written only from the app |
Boot. createRunContext starts the sandbox without waiting, so the boot
runs beside the model's first turns; a follow-up in the same chat re-attaches to
the same machine through the sessions row. Sessions boot from a snapshot
(TGIS_SANDBOX_SNAPSHOT_ID) carrying the Python stack and DuckDB's spatial
and httpfs extensions, not the tgis module: the module is written in at boot,
so a session always runs the working tree's version. Rebuild the snapshot with
pnpm sandbox:snapshot after changing PYTHON_PACKAGES, then redeploy.
Sandboxes are created with persistent: false, since a persistent one would
snapshot itself on every stop.
The worker. One tgis.worker process per session, reached over HTTP on an
exposed port with a bearer token generated per session; the token never enters
the model's context. Code passed to run_analysis runs at module level in a
namespace shared by every call, so a frame built in one call is there in the
next; a top-level return X is rewritten into an assignment. Analyses run one
at a time; operations called as tools run concurrently, and writes to the
manifest are locked. Files and the manifest are the durable state, and a worker
that dies is restarted from them. np, pd and gpd are preloaded beside the
long names. Code that raises comes back as analysis_error naming the
exception, with the figures and layers it made before the error kept; each new
layer comes back with its summary, and a layer tgis.layer lands is named from
its summary. Output longer than 4,000 characters keeps its start and its end.
Egress. The network policy swaps by phase on the live machine, in about
0.3 s, with the worker's state kept: allow-all while connectors fetch,
deny-all while model-written code runs. An allowlist built from the registry's
endpoints would not work — the bytes arrive from signed blob links, redirects
and S3 buckets the endpoints never name — and would still leave generated code
places to send data. Connector credentials are environment variables set at
creation; they never enter the model's context.
Auth, locally. @vercel/sandbox reads VERCEL_OIDC_TOKEN; pnpm sandbox:auth pulls it into .env.vercel, kept apart from .env.local because
vercel env pull and vercel blob create-store rewrite .env.local wholesale.
Cost. Active CPU at $0.128/hour with I/O wait not counted, memory at $0.0212/GB-hour, transfer out at $0.15/GB with downloads free. A twenty-minute session costs a few cents.
State, and how long it lives
| Store | Lifetime | Holds |
|---|---|---|
| Registry — Postgres | Forever | Sources, guidance, profiles, concept rows, places, observations. Knowledge |
| Event log — Postgres | Forever | Fetches, misses, computations, figures, guidance edits. What happened |
| Thread — Postgres | Forever | Each chat's questions and answers as UIMessage parts, every answer naming its run. What the planner saw |
Sandbox handle — sessions | One session | Worker URL, token, phase and expiry, keyed by the chat id. A tombstone once the machine stops |
Event log — /work/events.jsonl | One session | The sandbox's own half of the same log, flushed to Postgres at the end of a run |
Workspace — /work | One session | Layers, and the manifest as a fold over the events file. Data |
| Artefact cache — Blob | Until superseded | The bytes a connector made of one request, keyed by the request and by the code and configuration that answered it. Reached first by the request key, so a repeat skips the provider's version check; narrowing misses are kept the same way |
| Model context | One step | Handles and summaries. Never bytes |
Each store has its own lifetime, and one rule covers the last row:
Bytes never enter the model's context. Handles do.
This is already implemented and unnamed. toModelOutput hands the model a dozen
fields off a layer that is 120,675 road segments; the client renders the whole
thing and the run row keeps it. Naming the rule is what stops the next tool
returning a table body because it happened to be small that day.
One log, two views
v0 kept two incomplete records. The trace was read off result.steps, so it
knew every call and no provenance. The manifest was a map of layers, so it knew
provenance and nothing about a fetch that missed or an analysis call that
crashed — and misses leave no manifest entry at all, while misses are half the
product.
One append-only event log replaced both. Every fetch, miss, computation, figure
and guidance edit appends. The manifest is the latest write per layer_id; the
trace is traceOf over the same rows. That is the existing rule, nothing
stored that can be computed, applied to the sandbox, and it is why a session
can be read back after the workspace is gone.
runs.trace is still written beside it. Two records for one release is the
migration rather than a regression; the column goes when a recorded run replays
identically off the table.
The thread holds what was shown, not the workspace
A chat's id is chosen in the browser and kept in its URL, /chat/<id>, and the
same id is the sessions row's and every runs.session_id in the chat. The
client sends only its newest message; the route reads the thread before it from
messages, saves the question before the run starts and saves the answer when
the stream ends, aborted or not, with its tool parts, its data parts and the id
of the run that wrote it. The trace and the event log stay where they are and
are a join away.
The sidebar lists past chats from the same table: a chat's title is its first
question and its order is its newest message, both read by listChats rather
than kept on chats. Everyone through the access gate sees every chat; there
are no accounts to scope them by.
Nothing about the sandbox is copied into the thread. The tool parts already
carry every layer id, value id, provenance record and miss as the planner saw
them, which is what a reopened chat needs to show. What they point at — files
in /work, the worker's namespace, the run's value ledger — dies with the
machine, and a copy of it would be a record of state that no longer exists.
sessions is the live handle and stays one: a follow-up on a reopened chat
re-attaches if the sandbox is still up and boots a fresh one if not, as any
follow-up always has.
Value ids are numbered per chat, not per run. Each run's ledger starts empty,
and the first figure it mints takes the number after the highest value_id in
the thread's tool parts, so a follow-up never reissues v1 for a different
figure. An id from an earlier run is not in the new run's ledger: citing one
returns an unknown reference, not a figure.
Streams do not resume. Resumption needs a Redis-backed stream store this app does not have, and closing the tab aborts the run the way Stop does, so a reopened chat shows the answer as far as it got.
One format, two stores
The log cannot be a single Postgres table, because the sandbox has no database
credentials and must not get any. connectorEnv() passes four connector API
keys and nothing else, and tgis.load reading a Postgres view would put
DATABASE_URL on the machine that runs model-written Python.
So the log is one format written to two stores:
| Store | Written by | Holds |
|---|---|---|
/work/events.jsonl | tgis, append-only, one JSON object per line | The sandbox's own events. The manifest is a fold over it |
events in Postgres | The app, on flush | Every event, sandbox and app-side alike |
The worker returns the events it appended with each response; the client drains
them onto the run context; the run flushes once at the end, in after(), the
way telemetry already does. The app assigns seq on flush, so ordering belongs
to the app and not to a clock inside a sandbox.
The sandbox file is canonical during a session — it is what tgis.load reads
and it survives a worker restart. Postgres is canonical after one. Neither is
a summary of the other; they carry the same rows.
Sub-agents
The one part of this document that is not built. run_analysis is already a
delegation — work happens in an isolated context and a result comes back — and
the question is whether to add delegations that carry their own model.
Mechanically
There is no asTool helper in AI SDK 7. A subagent is a ToolLoopAgent wrapped
in a tool() whose execute calls .generate() and returns the text. Context
is isolated by default; the parent's history reaches it only if passed
deliberately, and passing all of it defeats the point. The criterion Vercel
gives is context cost: delegate work that reads a lot and returns a little.
Where it pays here
Discovery — the strongest case. find_sources already queries CKAN and
STAC live on a registry miss, and each protocol added widens what comes back: a
search returns two hundred results with long descriptions. The
main agent needs three source ids and one line on why. That is exactly reads a
lot, returns a little, and it is the one place in a run where wading through
prose is the job rather than a distraction from it.
Curation and harvest — yes, but not inside a run. A scheduled agent with its own lifecycle, writing provisional rows and guidance. It shares the mechanism and shares nothing else; wiring it into a question would put registry maintenance on a planner's latency budget.
Guidance distillation — possibly. An end-of-run agent that reads the event log and proposes the document edit, keeping that instruction out of the answering prompt. Cheap either way; not worth a second model until the prompt feels crowded.
Analysis — no, and this line holds. The corridor run's analysis is where the judgement lives: which layers, which weights, whether seventeen percent is a disagreement worth a planner's attention. Delegating it puts the reasoning that produced the answer into a context the planner cannot see, and the trace is the product.
Domain specialists — no. A transport agent and a climate agent is routing by
question type. It is the same mistake as a hardcoded top three in
resolve_place, wearing a bigger hat, and it fails
the first line every change is written to:
per-source facts, never per-question routing.
The rule
Code mints figures. The model never types one.
Sub-agents read. The model decides.
Delegate anything whose output is a shortlist or a digest of things read. Never delegate anything whose output is a figure, a choice between sources, or a judgement about whether two numbers disagree.
Two consequences
The trace shows a sub-agent as one row that expands. "Searched HDX and the
World Bank, three candidates" with its own steps underneath. That is better than
two hundred rows of search results, not worse, and it is one reason the trace
comes off the event log rather than off result.steps.
A sub-agent is another model round trip. Discovery-as-subagent buys context
and costs seconds. Measure it against the two-hundred-result bloat rather than
assuming; the budget assertions in pnpm agent:dry exist for exactly this kind
of question.
What does not change
One agent, one prompt, no fixed sequence. No plan step. A miss is a result.
Provenance by construction. present_figure refusing a typed number. Two ranked
lists from find_sources. A flat tool count as the registry grows.