Code generation
Experimental.
analyseData,processData,createTracker,clusterIncidentsandexecuteMaplibreCoderun model-written JavaScript. Their inputs, injected identifiers and output contracts may change without notice. In the browser, the data tools' code runs isolated with no DOM, storage or network. If the isolation can't start, they return an error instead.executeMaplibreCode, and the data tools under Node or SSR, run in your app's own realm, with its privileges. Read the threat model before shipping.
Five tools take JavaScript as a parameter instead of a fixed argument list (clusterIncidents optionally). The model writes the code. The plugin
compiles it with new AsyncFunction(...paramNames, code) and calls it with the injected inputs.
This lets the agent answer open-ended questions, such as "bar chart of POI categories", "union the boundaries of these cities" or "focus the camera on the worst traffic on the route", without a dedicated parameter for every variation. Each tool covers one intent, with any computation inside.
Where it shows up
| Family | Tools | What the code does |
|---|---|---|
| Analyse | analyseData |
Aggregate state into a result, such as counts, group-bys, top-N, hex bins, Chart.js configs or cross-kind correlations. Read-only. The result is recorded against every contributing entry and returned to the model. monitor: true recomputes it whenever a source entry changes, and monitorAnalysis switches that on or off later. |
| Process | processData |
Transform state into one new entry: places (with optional attached geometries and placeConnections), standalone geometries (a new customGeometries entry), or byod. fitOnMap moves the camera alongside any of them. |
| Track | createTracker |
Return a verdict { active, members, summary } over the same inputs as analyseData, saying whether a watched condition holds now. It recomputes whenever a source entry changes. |
| Cluster | clusterIncidents |
Optional: filter or combine the inputs, then return cluster(...). Without code, the tool runs its default clustering. |
| MapLibre | executeMaplibreCode |
Run arbitrary JS against the live map for anything no other tool covers, such as custom layers, animations or raster overlays. Returns a diff of added / removed / updated sources and layers. Not a default tool, so you add it yourself. |
Execution model
Each tool that accepts code builds an AsyncFunction whose parameters are a fixed set of
injected identifiers, then calls it. analyseData, processData and clusterIncidents narrow their inputs per turn. See
Scope-aware data tools.
Where the code runs:
- In the browser,
analyseData/processData/createTracker/clusterIncidentscode runs in a Web Worker inside a sandboxed, opaque-origin iframe. See Data-tool execution isolation. - If that iframe can't start, the same tools return an error to the model and run nothing. See If the sandbox does not start.
- Under Node or SSR, the same tools run on the main thread with only input copying and shadowed globals. These raise the bar, but they are not a boundary.
executeMaplibreCodealways runs in the page itself, with the livemapand none of those measures. That's why it's not a default tool.
The body works like any other async function body. Top-level await, locals and helper consts are fine, and the body must end with return …;. Note that:
codeis the function body, not a function expression. Writing(args) => { return result; }as the whole code creates an arrow and discards it. Drop the wrapper and end the body itself withreturn result;.- The sandbox is not a Node module.
requireis undefined, and ESimport/exportstatements don't parse inside the function body. The libraries you'd reach for (turf,h3,routeUtils,cluster) are already injected, and the sanitiser stripsconst x = await import(…)redeclarations of those names. - The sandbox cannot call other tools (no
functions.discoverPlaces(...), notools.X). To bring in new data, call the relevant tool before the code-generation tool and pass the resulting entry id in.
Pre-flight sanitisation
LLMs regularly prepend const turf = require('@turf/turf'), const h3 = await import('h3-js') or const placesByEntry = arguments[0].placesByEntry out of habit. These collide with the injected parameters and would throw at parse time ("Identifier 'X' has already been declared") before the body runs.
The plugin removes those lines, but only if the right-hand side is one of:
require(…)import(…)(with or withoutawait)arguments[N].nameorarguments[N]['name'], or the same without[N]
A const geometriesByEntry = … that genuinely shadows an injected name (e.g. const geometriesByEntry = placesByEntry['places-2'].features.map(...)) stays untouched. This is deliberate, because a naive name-only filter would silently corrupt legitimate user code.
Input copying
The plugin copies the injected state inputs before they reach the code, on every path. These are the per-entry records such as placesByEntry, plus previous / now on monitored runs. The browser's isolated run receives a structured clone. Mutating them in place is safe and local:
// Sorting/splicing the injected collection touches a throwaway copy, never live state.
const places = placesByEntry['places-2'];
places.features.sort((a, b) => a.properties.score - b.properties.score);
return { places: { type: 'FeatureCollection', features: places.features.slice(0, 10) } };
Without the copy, an in-place mutation would corrupt the entry the data came from and leak into later turns. The libraries are passed through unchanged.
Shadowed globals
Inside the data tools' code (every tool above but executeMaplibreCode), the plugin binds a fixed set of network, storage, DOM and process globals to undefined, wherever the code runs. They are fetch, XMLHttpRequest, WebSocket, EventSource, localStorage, sessionStorage, indexedDB, document, window, globalThis, self, navigator, importScripts and process. Code that reaches for one fails loudly instead of making a network call or touching browser state.
This is a tripwire, not a security boundary. The same capabilities stay reachable through constructor walks (({}).constructor.constructor) and other realm escapes, so it catches accidental access, not a determined or jailbroken model. executeMaplibreCode is not shadowed, because it needs live browser access. In the browser, the isolated run is the boundary.
Injected identifiers
| Tool | Injected identifiers |
|---|---|
analyseData |
placesByEntry, routesByEntry, incidentsByEntry, geometriesByEntry, trafficAreaAnalyticsByEntry, byodByEntry, h3, turf, routeUtils, cluster (plus previous, now, log when the analysis is monitored) |
processData |
same as analyseData, without the monitor extras |
clusterIncidents |
same as analyseData, with previous, now and log on every run (previous holds the last clustering while the recipe stays the same) |
createTracker |
same as processData on the first run; re-runs add now and log, with previous always undefined |
executeMaplibreCode |
map (live MapLibre Map instance) |
There is one per-entry record per kind, mapping an entry id to that entry's data, and no merged variable:
placesByEntry-Record<entryId, FeatureCollection<Place>>routesByEntry-Record<entryId, FeatureCollection<Route>>incidentsByEntry-Record<entryId, TrafficIncident[]>(values are plain arrays)geometriesByEntry-Record<"${kind}:${id}", PolygonFeature[]>, keyed by the composite tagged source${kind}:${id}, e.g."customGeometries:abc", not a bare id. Values are plain arrays, and each feature carriesproperties._source: { kind, id }.trafficAreaAnalyticsByEntry-Record<entryId, TrafficAreaAnalytics>. Each value is a fullFeatureCollectionthat keeps its collection-levelproperties, with no cross-entry merge.byodByEntry-Record<entryId, FeatureCollection>
Read one entry by id (placesByEntry['places-2']). To span every requested entry of a kind, merge with Object.values(...). Use Object.values(placesByEntry).flatMap((fc) => fc.features) for the FeatureCollection kinds (places / routes / byod / trafficAreaAnalytics), and Object.values(incidentsByEntry).flat() for the array kinds (incidents / geometries).
A record is undefined if the tool call left out its *EntryIDs argument, so code must guard before reading (if (placesByEntry) ..., placesByEntry['places-2']?.features.length, etc.). Scope narrowing trims which kinds the tool documents in its prompt, but the sandbox always carries the full set of names. Out-of-scope kinds arrive as undefined.
Shared libraries
The four data tools get four libraries. executeMaplibreCode gets none, only map:
turf- @turf/turf v7:area,length,bbox,centroid,union,intersect,difference,buffer,distance,booleanPointInPolygon,pointsWithinPolygon,clustersDbscan,convex, etc.h3- h3-js, for hex-grid math only (latLngToCell,cellToLatLng,cellToBoundary,polygonToCells,gridDisk,cellArea).routeUtils- SDK route section and progress helpers, useful ifroutesByEntryis in scope:getSectionBBox,getRouteProgressForSection,getRouteProgressBetween,calculateProgressAtRoutePoint,getCoordinateAtRouteProgress,getProgressAtNearestRoutePoint,getSectionsWithin,getImportantRoads,compareRoutesandisHairpin. For a plain route bounding box,turf.bbox(route)is enough.cluster(incidents, params?, previous?, now?)- The DBSCAN incident clustering behindclusterIncidents.paramstakeseps,minMembers,maxClustersandpreFilter.
All four are pure computation. None does spatial search, place lookup or HTTP. To fetch new places, the agent must call discoverPlaces (or locatePlace) before the code-generation tool and pass the resulting entry id in.
Turf input gotchas
Turf is fussy about input shape. If it throws, the runtime hints at the right pattern, but knowing the rules up front avoids the retry loop:
- A single entry of a FeatureCollection kind (
placesByEntry[id],routesByEntry[id],trafficAreaAnalyticsByEntry[id],byodByEntry[id]) is aFeatureCollection, so pass it directly:turf.bbox(placesByEntry['places-2']). Neverturf.bbox(placesByEntry['places-2'].features). - Iterate arrays (
incidentsByEntry[id],geometriesByEntry[key], or any mergedObject.values(...)list) per feature (for (const f of incidentsByEntry[id]) turf.X(f)), or wrap them withturf.featureCollection([...]). - Never feed turf with
.map(f => f.geometry.coordinates).flatMap(...). That strips the feature wrapper turf reads, and either throws"coordinates must be an Array"or silently mixes Point/LineString shapes. - Incidents mix Point and LineString geometries. Guard with
if (inc.geometry.type === "LineString")before line-only operations. - In Turf v7, the set operations
union/intersect/differencetake oneFeatureCollection:turf.difference(turf.featureCollection([outer, inner])) // v7 ✓ turf.difference(outer, inner) // v6 — throws in v7
Performance hints
For large-N nearest/within queries, pre-bucket points with h3 before running turf:
const cells = points.map((p) => h3.latLngToCell(p.geometry.coordinates[1], p.geometry.coordinates[0], 8));
// Only run turf on same- or neighbour-cell pairs — O(N+M) instead of O(N·M).
Pick res so each cell is approximately your query radius (h3 res 8 ≈ 460 m edge, res 9 ≈ 175 m).
Output contract
analyseData: outputFormat
"json"(default) - Any JSON-serializable value, rendered as text in the chat."chart"- A Chart.jsChartConfiguration({ type, data, options? }). The chat UI passes the config straight tonew Chart(ctx, analysis).
The accepted chart types are bar, line, pie, doughnut, radar, polarArea, scatter and bubble. The tool rejects a config with any other type before the result reaches the model.
For per-point detail you'd normally put in a Chart.js tooltip.callbacks function, use a string[] label, because functions don't survive the JSON boundary. Chart.js renders the label multi-line both on the axis tick and in the tooltip title.
processData envelope
processData code returns { places?, placeConnections?, geometries?, fitOnMap?, byod? }, with at least one of places, geometries, fitOnMap or byod:
places(with optional attachedgeometries), standalonegeometriesandbyodare mutually exclusive. The tool writes one per call and drops the others.placeConnectionsdraws lines between output places and requiresplaces.fitOnMapmoves the camera and composes with any of them.
Return-value validation
analyseData runs its return value through three checks before handing it to the model, as do clusterIncidents
code and tracker re-runs. processData validates its envelope instead, and executeMaplibreCode returns its value
as is:
- Not
undefined. If the body doesn'treturn(e.g. the code was written as(args) => { return … }so the arrow is created and discarded), the tool surfaces a targeted error telling the model to drop the arrow wrapper. JSON.parse(JSON.stringify(value)). This convertsNaN,Infinity,undefinedobject values and sparse arrays tonullor drops them. These silent-poison values would otherwise break the next turn'sModelMessagevalidation. Circular references throw and surface as a clear error for the model.- Chart shape check (for
outputFormat: "chart"only). Thetypemust be in the whitelist above anddatamust be a non-null object.
As a result, anything that comes out of the sandbox is either safe to round-trip through the model's next prompt, or surfaces a precise diagnostic the model can act on.
Self-correcting error hints
When sandbox code throws, the runtime matches the message against a curated list of common pitfalls and appends a targeted Hint: to the error returned to the model. The model usually fixes the code on the next attempt.
The recognised patterns include:
| Pattern | What the hint redirects toward |
|---|---|
Cannot read property of undefined/null |
Missing ?. / ??, or a reduce callback without return acc / initial value. |
h3.X is not a function / turf.X is not a function |
Library purpose (hex math / GeoJSON geometry). Rejects spatial-search / HTTP use and suggests discoverPlaces instead. |
coordinates must be an Array / Unknown Geometry Type |
Pass the feature, not bare coordinates. Guard mixed-kind arrays like incidentsByEntry[id]. |
Illegal return statement |
Mismatched braces in a multi-line .map((item) => { ... }). |
Identifier 'X' has already been declared |
Dropped redeclaration (rare after the sanitiser). |
Unexpected token / identifier / end of input |
Body didn't parse as an async-function body, because of unbalanced braces or a stray import/export. |
require is not defined / import outside a module |
The sandbox is not a Node module. Inputs and libraries are already injected. |
functions / tools / discoverPlaces / recallState is not defined |
The sandbox can't call other tools. List entry ids in the tool input, or call the tool before this one. |
is not iterable |
Default with ?? [] before for..of / spread / destructuring. |
Cannot access 'X' before initialization |
Temporal dead zone. Move the declaration above the first use. |
Assignment to constant variable |
Use let or mutate in place. |
structuredClone / DataCloneError / could not be cloned |
The return value isn't pure JSON (no functions, no classes, no DOM/Map refs). Use string[] labels instead of tooltip callbacks. |
The tool descriptions therefore needn't spell out every guardrail. The runtime corrects the model when it trips.
Threat model
In the browser, the data tools' code is isolated. Everywhere else, the security boundary is the model and the prompt path you let users drive, not the runtime. Input copying and shadowed globals stop accidental mutation and casual global access, not a determined model. Treat that code like a <script> tag controlled by your model provider:
executeMaplibreCode, if you add it, holds the livemapand, through it, the page. A jailbroken or compromised model can exfiltrate user data or rewrite the page.- Data tools on the main thread (Node, SSR, or a browser that set
allowMainThreadFallbackand whose iframe failed to start) can reachfetch, storage and the DOM through realm escapes such as({}).constructor.constructor("return globalThis")(). - Prompt injection from a tool result, such as a place description with adversarial text, can steer the model into writing such code.
If your deployment can't tolerate this, for example on a page with privileged session cookies or sensitive in-browser state, remove the code-generation tools:
const agent = createMapAgent(map, {
model: openai('gpt-4o'),
tools: {
analyseData: false,
processData: false,
createTracker: false,
clusterIncidents: false,
},
});
The fixed-schema tools (locatePlace, setRoute, findReachableAreas, the display tools, …) stay usable either way.
Adding executeMaplibreCode
executeMaplibreCode isn't in DEFAULT_TOOLS, because its code runs in your page with no sandbox. Add it only where you trust the model and every prompt and tool result it reads, such as an internal tool behind your own sign-in:
import { createExecuteMaplibreCodeTool, createMapAgent } from '@tomtom-org/maps-sdk-plugin-agent-toolkit';
const agent = createMapAgent(map, {
model: openai('gpt-4o'),
tools: { executeMaplibreCode: createExecuteMaplibreCodeTool() },
});
Without it, the agent has no tool for raster overlays or time-based animations.
Data-tool execution isolation (experimental)
In the browser, the data tools' code runs off the main thread in a locked-down realm, a Web Worker inside a sandboxed, opaque-origin <iframe>. The environment picks where it runs (selection by environment). A page without a Content Security Policy needs no setup, and a page with one must allow what the sandbox needs. To tune the browser run, use the optional codeExecution:
const agent = createMapAgent(map, {
model: openai('gpt-4o'),
codeExecution: {
timeoutMs: 5000, // wall-clock budget per isolated run (default 10000)
},
});
What this buys you, and what it doesn't:
- Opaque origin (
sandbox="allow-scripts", noallow-same-origin) - The code can't read the parent's cookies,localStorageor DOM. - No network - The iframe's CSP is
default-src 'none', sofetch, XHR, WebSocket and beacon all fail. The data is injected, so the sandbox has no legitimate network need. - Termination - The plugin kills a runaway or infinite-loop body at
timeoutMswithout freezing the main thread. It terminates the worker and respawns it. - The iframe is long-lived. The worker stays warm, and each call runs in a fresh function scope.
How the pieces fit
The generated code runs two hops away from your app, across an opaque-origin iframe and into a Web Worker. postMessage + structuredClone is the only channel between them:
Each boundary stops one threat. The opaque origin stops DOM and storage access, the CSP stops network egress, and worker termination stops runaway loops. The libraries are bundled into the worker, so generated code never imports them.
Why a worker inside an iframe
The two-layer nesting isn't redundant. Each layer provides a boundary the other can't:
- Why a worker, not just script in the iframe. The iframe alone gives the opaque origin and the CSP, but code running on the iframe's own thread can't be stopped. A synchronous
while (true) {}blocks that thread's event loop, and nothing outside can interrupt a running thread.worker.terminate()is the only reliable kill switch, so the generated code runs in a worker that can be killed and respawned on timeout, leaving the warm iframe intact. - Why an iframe, not just a bare worker. A bare worker inherits its owner document's CSP. A stricter
default-src 'none'would require serving the worker script with aContent-Security-Policyresponse header, so the consuming app's server would have to cooperate. The SDK ships to arbitrary hosts and can't require that. A<meta>CSP authored in the iframe'ssrcdoctravels with the SDK code and needs no host config. This is the decisive reason for the iframe.
Running off the main thread is also why heavy turf/h3 computation over large FeatureCollections doesn't freeze the map UI. The postMessage + structuredClone hop still pays a serialization cost at each edge, so it's a net win for compute-heavy bodies, not zero main-thread work.
Worker libraries (turf / h3 / routeUtils / cluster)
The worker can't import your app's modules, so it needs the libraries as one self-contained script. The plugin ships them in a separate lazy chunk, loaded only when the isolated run first starts, so your main bundle is unaffected.
To pin a build, override it with loadWorkerLibrarySource. Return UMD or IIFE JavaScript that defines self.turf and self.h3, plus self.routeUtils and self.cluster if generated code uses them. The override replaces the bundled source, so re-supply every namespace your code relies on:
codeExecution: {
loadWorkerLibrarySource: async () => {
const turf = await (await fetch('/vendor/turf.min.js')).text();
const h3 = await (await fetch('/vendor/h3-js.umd.js')).text();
// turf first — routeUtils reads self.turf. Append your own routeUtils / cluster
// scripts here too if your generated code calls them.
return `${turf}\n;${h3}`;
},
},
What your page must allow
The sandbox starts on the first code run. It needs three things from your page:
- Content Security Policy - The iframe is a
srcdocdocument, so it and its worker inherit your page's CSP on top of their own. If your page sends one, itsscript-srcmust allow'unsafe-inline'for the iframe's start-up script and'unsafe-eval'for compiling the generated code. The worker loads from ablob:URL, soworker-srcmust allowblob:, orscript-srcif you don't setworker-src.frame-srcneeds nothing, because asrcdociframe loads no URL. - The worker libraries - The plugin downloads the worker libraries' lazy chunk (
worker-libs-*.js, about 770 kB before compression) from wherever you serve the plugin's other chunks, soscript-srcmust allow that origin too. WithloadWorkerLibrarySource, your function's own requests apply instead. The download has no timeout of its own, and a failed download fails the start. - The start-up timeout - The iframe's start-up script must report within 2 seconds, and after the download the worker must report within another 2 seconds.
timeoutMsdoesn't change these, because it bounds each run.
For example, a policy that lets the sandbox start:
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; worker-src blob:
Note that browsers ignore 'unsafe-inline' when script-src also lists a nonce or a hash, so the sandbox can't start under such a policy. The sources above relax your page's own policy too. If your page can't allow them, remove the data tools instead.
If the sandbox does not start
If the iframe doesn't start, the code-running tool returns an error to the model, runs nothing and logs a console warning once. A monitored analysis or tracker skips that re-run. A call 30 seconds or more later tries to start the iframe again, so a failure that passes, such as a dropped download, recovers on its own. A page with no Worker fails the same way. With allowMainThreadFallback, that failure is final: later runs stay in the page and do not try the iframe again.
To see where generated code runs, call agent.getCodeExecutionMode():
| Mode | Where the code runs |
|---|---|
not-started |
Nowhere yet. In the browser, the iframe starts on the first code run. |
iframe-worker |
In the isolated Web Worker. |
unavailable |
Nowhere. The last start failed, so the data tools return errors until a start 30 seconds or more later succeeds. |
main-thread |
In your page, not isolated. This is Node or SSR, or a browser that set allowMainThreadFallback and whose iframe failed to start. |
For example, to tell your users when data analysis is off:
const agent = createMapAgent(map, {
model: openai('gpt-4o'),
onToolExecute: () => {
if (agent.getCodeExecutionMode() === 'unavailable') showNotice('Data analysis is unavailable on this page.');
},
});
To run the code in your page when the iframe can't start, set allowMainThreadFallback. The code then has your page's privileges, as the threat model describes, so turn it on only where you trust the model:
codeExecution: {
allowMainThreadFallback: true, // not isolated: runs generated code in the page if the iframe can't start
},
Selection by environment
The environment chooses where the code runs:
| Environment | Runs in | Why |
|---|---|---|
| Browser | iframe-worker | The boundary is the iframe's opaque origin + CSP egress-block + worker termination. |
| Node / SSR | main thread | None of those layers exist server-side. A worker_thread would add only termination while exposing fs / net / child_process, which is isolation in name only, so we don't spin one up. The main-thread runner still deep-copies inputs so code can't corrupt live state, but it's defense-in-depth, not a boundary. |
In the browser, the code runs in your page only if you set allowMainThreadFallback and the iframe fails to start. That choice does not retry the iframe.
Experimental. The isolation depends on real-browser behaviour — CSP enforcement, opaque-origin iframes, worker termination — which an end-to-end suite verifies in real Chromium on every CI run.
executeMaplibreCodeis not isolated, because it needs the live map.
Example
A user asks "Bar chart of how many places of each category we have." The classifier picks analyseData with toolScopes.analyseData = { kinds: ['places'] }, which narrows its schema to places. The model emits:
analyseData({
placesEntryIDs: ['places-2'],
name: 'category-bar',
outputFormat: 'chart',
code: `
const counts = {};
for (const p of placesByEntry['places-2'].features)
for (const c of (p.properties.poi?.categories ?? []))
counts[c] = (counts[c] ?? 0) + 1;
const entries = Object.entries(counts).sort((a, b) => b[1] - a[1]);
return {
type: 'bar',
data: {
labels: entries.map((e) => e[0]),
datasets: [{ label: 'Places', data: entries.map((e) => e[1]) }],
},
};
`,
});
The plugin runs the body, validates the Chart.js config, records it as category-bar against the source entry, and returns it to the model. The chat UI receives the same config and renders the chart inline.
Cross-kind composition
turf and h3 work on every feature, whichever record it came from, so one call can combine placesByEntry (Points), routesByEntry (LineStrings), incidentsByEntry (Point or LineString), geometriesByEntry (Polygon / MultiPolygon), trafficAreaAnalyticsByEntry (Polygon tiles with metrics) and byodByEntry (mixed GeoJSON).
A few common bridges:
- Point ↔ LineString -
turf.pointToLineDistance(point, lineFeature, { units: "meters" }),turf.nearestPointOnLine(line, point). - Point ↔ Polygon -
turf.booleanPointInPolygon(point, poly),turf.pointsWithinPolygon(turf.featureCollection(points), poly). - LineString ↔ Polygon -
turf.lineIntersect(line, poly),turf.booleanCrosses(line, poly),turf.lineSplit(line, poly). - Polygon ↔ Polygon -
turf.union/intersect/difference(turf.featureCollection([a, b]))(Turf v7 takes a single collection). - Point/LineString ↔ trafficAreaAnalytics tile - Iterate
trafficAreaAnalyticsByEntry[id].features(orObject.values(trafficAreaAnalyticsByEntry).flatMap((fc) => fc.features)) to find which tile a place or route segment falls into, and read its metric (tile.properties.congestionLevel, etc.). - Buffer to bridge kinds -
turf.buffer(anyFeature, meters/1000, { units: "kilometers" })turns a point or line into a polygon you can then filter against. - h3 -
h3.latLngToCell(lat, lng, res)for any Point,h3.polygonToCells(poly.geometry.coordinates, res)for any Polygon.
The arguments are GeoJSON Features. Pass the whole feature (placesByEntry[id].features[i], routesByEntry[id].features[0], geometriesByEntry[key][i], …), not the bare geometry.coordinates. A few helpers accept coordinates directly, but most expect Features.
Related
- Scope-aware data tools — how the per-turn scope mechanism keeps the prompt small.
- Bring your own data — feeding BYOD layers into the sandbox via
byodEntryIDs. - Customizing tools — removing the code-generation tools, or wrapping them with policy.
- State — how
processDataoutputs land as new entries. - Securing your agent — where the sandbox sits among the limits the toolkit does and does not enforce.