TGIS v.0

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.

SurfaceWhereCalled byWhat is there
ToolsTypeScript, in the requestThe modelThe verbs. Eight, flat, and they do not grow as the registry grows. Computing is one of them, run_analysis
Protocol clientsTypeScript, serverTools. Never the modelCatalogue search, direct cell reads, provenance minting
The tgis modulePython, in the sandboxCode the model writes, in run_analysisload, zonal_sum, buffer and the other operations; layer and value to bring results back
ConnectorsPython, in the sandboxThe module. Never the modelLanding bytes, sniffing formats, refusing to guess a narrowing
The model reaches the tools and the tgis module. The protocol clients and the connectors are closed to it, and that is where a value acquires its provenance. CLOSED TO THE MODEL The model chooses, every step Tools TypeScript · the verbs The tgis module Python · code the model writes Protocol clients search · direct reads · minting Connectors bytes · formats · narrowings The model chooses. Code mints. A figure acquires its provenance inside the dashed box, so no path leaves the model holding a number code did not construct.

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 numberpresent_figure takes a reference, not a value
The lineagemanifest.derive builds derived_from from named input layers
The publisher, licence and titleA 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

Two paths out of find_sources. Path A reads one cell through a connector and never touches the sandbox. Path B lands a layer in the session sandbox, computes over it, and mints a value. Both end at present_figure. ASK the model chooses, every step find_sources the registry, then upstream A candidate becomes a row the first time it is fetched, so every row has been called once. PATH A — ONE CELL connector.read() one request, no workspace Value PATH B — A COMPOSITION THE SESSION SANDBOX fetch connector Layer a handle, never bytes tgis operations named, or your Python Value present_figure takes a reference the answer The sandbox exists to hold data. It boots with the run; a direct read never puts anything in it. Both paths mint inside code. Neither hands the model a number to type.

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):

PhasePolicyWhat is running
Fetchingallow-allConnector code we wrote
Analysisdeny-allModel-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.

A fetch lands a layer; a named operation or your own Python computes over it; tgis.value and tgis.layer bring the result back; present_figure takes a reference and the answer states it. RETRIEVE fetch connector Layer handle + provenance COMPUTE a tgis operation zonal_sum · count_points · buffer your own Python pandas · geopandas · shapely MINT tgis.value(…, label=…) · tgis.layer(frame or array) registers where it is called, whatever the code returns present_figure takes a reference the answer a value_id a minted figure a layer_id its feature count layer_id#col?k=v one cell of a table

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 askingWhat 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.

Across five traced runs the share of analysis calls spent establishing shape rather than doing work barely moved, until the module's own surface was fixed. Analysis calls spent establishing shape, not doing work The registry closed rediscovery of the data. The question became what our own code returns. BEFORE THE REGISTRY CARRIED SHAPE corridor 11 of 15 AFTER IT DID — SAME RATIO, A DIFFERENT UNKNOWN corridor 4 of 5 transit gaps 4 of 6 sub-city LST 6 of 6 AFTER THE MODULE’S OWN SURFACE WAS FIXED sub-city LST, again 1 of 2 A fact belongs where it is consumed. A profile arrives in the tool result before the fetch, and worked. An API card arrives once, at the start of a run, and did not.

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

LayerWhat
AppNext.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 recordNeon Postgres with PostGIS, pgvector and the Lakebase search extensions
WorkspaceOne Vercel Sandbox per conversation: Python 3.14, geopandas, rasterio, rasterstats, DuckDB
BytesVercel 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

StoreLifetimeHolds
Registry — PostgresForeverSources, guidance, profiles, concept rows, places, observations. Knowledge
Event log — PostgresForeverFetches, misses, computations, figures, guidance edits. What happened
Thread — PostgresForeverEach chat's questions and answers as UIMessage parts, every answer naming its run. What the planner saw
Sandbox handle — sessionsOne sessionWorker URL, token, phase and expiry, keyed by the chat id. A tombstone once the machine stops
Event log — /work/events.jsonlOne sessionThe sandbox's own half of the same log, flushed to Postgres at the end of a run
Workspace — /workOne sessionLayers, and the manifest as a fold over the events file. Data
Artefact cache — BlobUntil supersededThe 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 contextOne stepHandles 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:

StoreWritten byHolds
/work/events.jsonltgis, append-only, one JSON object per lineThe sandbox's own events. The manifest is a fold over it
events in PostgresThe app, on flushEvery 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.

References