How to Keep AI Agents From Going Rogue Using Docker Sandbox
AI Agents and Frontier Models: Insights From The Neuron
Claude Code has changed how many people write software. You type in the terminal, ask for a change, and the tool reads files, edits code, runs commands, and reports back. For a lot of daily work, it feels fast and useful. Still, 2 problems show up again and again. First, you hit usage limits. A long coding session can stop in the middle of a task. You wait, switch accounts, or break the flow. Second, every new session starts blank. The tool does not remember the bug you fixed yesterday, the library versions you prefer, or the style rules you care about. You spend the first part of the day explaining the same project again. Those two issues waste time. They also make the tool feel less like a partner and more like a one-shot helper. A small set of real tools fixes most of that pain. This article walks through 5 of them in plain language. OmniRoute helps when you hit limits by switching to other models. claude-mem keeps project memory across sessions. Headroom shrinks big tool outputs so you use fewer tokens. Claude Code Setup (from Anthropic) looks at your repo and suggests useful helpers. Task Observer watches how you correct the AI and slowly learns your style. These tools do not give you free top models forever. Free options help as a backup. Paid models stay the better choice for hard or important work. The goal is simple: fewer stops, less repeated explanation, and more time spent shipping code. 5 Tools (Along With Links) OmniRoute – runs on your computer and sends requests to many AI providers. When one is full or slow, it can switch to another (including free ones). Link: https://github.com/diegosouzapw/OmniRouteclaude-mem – remembers what happened in past sessions and brings the useful parts back next time. It keeps data on your machine by default. Link: https://github.com/thedotmack/claude-memHeadroom – cuts down big tool outputs and repeated text before they go to the model. The full text stays on your computer if needed later. Link: https://github.com/headroomlabs-ai/headroomClaude Code Setup – official tool from Anthropic. It looks at your code and suggests the best hooks, skills, and helpers for your project. It only suggests; it does not change files by itself. Link: https://claude.com/plugins/claude-code-setup (also in the official marketplace claude-plugins-official)Task Observer – watches when you fix the AI's code or give better instructions. Later, it can suggest updates so the AI matches your style more. Link: https://github.com/rebelytics/one-skill-to-rule-them-all Start With This First Put a clear CLAUDE.md file in your project. Claude Code already uses project notes and some memory. The tools below work better when that file is good. 1. Handle Limits With OmniRoute OmniRoute sits on your computer. Claude Code talks to it instead of only to one company. OmniRoute can try a paid model first, then switch to free ones if needed. It does not give you free Claude models. It just lets you keep going when the main path is blocked. Free models change, have limits, and are not always as good. Use them when you are stuck. Use paid models for important work. 2. Remember the Project With claude-mem Normal sessions forget everything when they end. claude-mem notes useful things while you work, shortens them, saves them on your computer, and brings the right parts back next time. You can also search past notes. 3. Use Fewer Tokens With Headroom Long sessions and memory make prompts bigger. Tool outputs and logs eat tokens fast. Headroom shrinks the noisy parts before they reach the model. The full text stays on your computer if the model needs it later. How much you save depends on the work, but coding sessions usually get a clear drop. 4. Set Up the Project With Claude Code Setup A messy project makes the AI guess wrong more often. Missing helpers, unclear structure, or no useful hooks lead to extra steps and more mistakes. Claude Code Setup is the official Anthropic plugin that helps fix that. It does not rewrite your code. It only looks and suggests. What It Does Claude Code Setup scans your repository. It checks things like: File and folder layoutLanguage and framework signals (for example, package.json, language files, common config files)Dependencies and patterns already in the project From that scan, it recommends the most useful automations in five areas: MCP servers – connections to outside tools, docs, browsers, databases, and similar servicesSkills – ready-made instruction packs for common tasksHooks - actions that run at key moments (for example, after an edit, before a commit, or when a file changes)Subagents – smaller specialist agents (for example, security review or performance review)Slash commands – short commands you can type for repeated workflows It usually surfaces the top one or two ideas in each area. If you ask for one type only (for example, "what MCP servers should I use?"), it can give a longer list. How to Use It Install it from the official marketplace inside Claude Code: Plain Text /plugin install claude-code-setup@claude-plugins-official Or open the plugin browser and pick it from the official list. Link: https://claude.com/plugins/claude-code-setup. Then talk to it in plain words, for example: "recommend automations for this project""help me set up Claude Code""what hooks should I use?""what MCP servers fit this repo?" It stays in read-only mode. It does not change files by itself. You review the suggestions and decide what to add. Why It Helps Without this step, the agent often works with a half-ready environment. It may miss useful tools, skip helpful hooks, or invent steps that a good setup would handle automatically. After you apply the best suggestions, the same agent makes fewer wrong guesses and needs less hand-holding. Simple Example You open a React + Node project. Claude Code Setup might suggest: A browser or Playwright MCP for frontend checksA docs or package MCP for library lookupHooks that format or lint after editsA security-focused subagent if it sees auth codeSlash commands for common test or PR steps You pick the ones that fit, install them, and keep going. Where It Sits in the Stack Run it early. A clear CLAUDE.md plus a tuned project setup gives the other tools (memory, compression, routing, style learning) a cleaner base to work on. In short: Claude Code Setup is the official way to audit your repo and get practical, project-specific recommendations. Use it once on a new or large project, apply the highest-value ideas, and the rest of the stack works with less friction. 5. Learn Your Style With Task Observer You fix the same style issues again and again. Task Observer notes those fixes and can suggest skill updates. You check the ideas first. Over time, the AI starts closer to how you write. How the Pieces Fit Good Order to Add Them Write or improve CLAUDE.md.Run Claude Code Setup and add the best ideas.Add claude-mem so sessions remember the project.Add Headroom if tokens or context size become a problem.Add OmniRoute when you hit limits often.Add Task Observer once the rest is stable and you want better style match. Safety Notes OmniRoute, Headroom, and claude-mem (default) run on your computer.Free providers and any cloud sync send data outside. Check what they get.Only install tools you trust. Official Anthropic tools and well-known open projects are safer, but you still choose what runs.The extra layers add a little delay. On most computers, it is small next to model time. Keep Expectations Real Free models are not unlimited top quality. Limits and quality change. Paid models are still best for hard or important work. This stack mainly stops the daily restart and limit pain so you can code more. What About GitHub Copilot? Copilot has its own tools. This stack is made for Claude Code. Some parts can be reused: OmniRoute can work with tools that accept a custom address.Headroom works with several coding tools.claude-mem works with some other tools, but not the same way. Do not expect the same Claude Code Setup tips or Task Observer skill inside Copilot. Treat Copilot as a different setup that can share some routing or shrink tools, not the full stack. Closing Use a clear CLAUDE.md, OmniRoute for model switching, claude-mem for memory, Headroom for smaller prompts, Claude Code Setup for project fit, and Task Observer for style. You still face real limits. You just fight them less and ship more code.
If you've been following the database world lately, you'll have noticed DuckDB turning up in some unexpected places. One of the more interesting examples is pg_duckdb, a Postgres extension that embeds DuckDB directly into the Postgres process, letting analytical queries run through DuckDB's engine without leaving the database. It's a clever idea: keep your data where it is, but bring a faster analytical engine. That got us wondering: could we do something similar with Neo4j? The answer is not in the same way. Postgres has a mature extension architecture that allows foreign query engines to be embedded at a low level. Neo4j doesn't expose that kind of hook. But Neo4j does let you write custom procedures in Java that run inside the Neo4j JVM and are callable directly from Cypher. Also, DuckDB ships an official JDBC driver that bundles its native binary and loads it via JNI. Put those two things together and you've got something that feels surprisingly close to the pg_duckdb experience: DuckDB running in-process, queries fired from Cypher, results flowing back into a graph. That's what we built. In this article, we'll walk through the whole thing from scratch. The full source code is available on GitHub. Prerequisites Before starting, we'll need the following: JDK 21 – check with java -versionMaven – check with mvn -versionNeo4j Desktop – with a local database instance created and ready to start Note that this approach requires a self-managed Neo4j install. AuraDB doesn't support custom plugins, so Neo4j Desktop is the right choice here. Version Summary For reference, here are all the component versions used in this article: ComponentVersionOpenJDK21.0.12.1Maven3.9.6Neo4j Desktop2.2.1Neo4j2026.07.0Neo4j Procedure API5.26.0DuckDB JDBC1.1.3JUnit Jupiter5.10.2maven-compiler-plugin3.11.0maven-shade-plugin3.5.2maven-surefire-plugin3.5.4 Getting the Code The full source code is on GitHub. To get just this project: Shell cd ~ git clone --filter=blob:none --sparse https://github.com/VeryFatBoy/neo4j.git cd neo4j git sparse-checkout set duckdb-procedure mv duckdb-procedure ../duckdb-procedure cd ../duckdb-procedure/neo4j-duckdb-procedure Or follow along from scratch using the steps below. How It Works The architecture is straightforward. We write two Java classes and package them as a fat jar: DuckDBConnection manages a singleton DuckDB connection that lives for the lifetime of the Neo4j process. It uses double-checked locking to keep things thread-safe and reconnects automatically if the connection is ever closed.DuckDBProcedure is the Neo4j procedure itself. It accepts a SQL string from Cypher, runs it against the shared DuckDB connection and streams the results back as rows, each one a Map<String, Object> that Cypher can destructure with dot notation. DuckDB runs in-memory by default, which means it starts clean every time Neo4j restarts. That's fine for analytics against external files, and we'll discuss file-backed persistence in the article Summary section. Project Setup Start by creating the project directory: Shell mkdir -p neo4j-duckdb-procedure/src/main/java/com/example/duckdb cd neo4j-duckdb-procedure Verify the structure: Shell find . -type d Now create pom.xml in the project root. XML <?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>com.example</groupId> <artifactId>neo4j-duckdb-procedure</artifactId> <version>1.0.0</version> <packaging>jar</packaging> <properties> <java.version>21</java.version> <maven.compiler.source>21</maven.compiler.source> <maven.compiler.target>21</maven.compiler.target> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <neo4j.version>5.26.0</neo4j.version> <duckdb.version>1.1.3</duckdb.version> </properties> <dependencies> <!-- Neo4j procedure API, provided by Neo4j at runtime --> <dependency> <groupId>org.neo4j</groupId> <artifactId>neo4j</artifactId> <version>${neo4j.version}</version> <scope>provided</scope> </dependency> <!-- DuckDB JDBC driver, bundled with native binary --> <dependency> <groupId>org.duckdb</groupId> <artifactId>duckdb_jdbc</artifactId> <version>${duckdb.version}</version> </dependency> <!-- Test dependencies --> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <version>5.10.2</version> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.11.0</version> <configuration> <source>21</source> <target>21</target> </configuration> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-surefire-plugin</artifactId> <version>3.5.4</version> </plugin> <!-- Fat jar: bundles DuckDB JDBC into the plugin jar --> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-shade-plugin</artifactId> <version>3.5.2</version> <executions> <execution> <phase>package</phase> <goals> <goal>shade</goal> </goals> <configuration> <createDependencyReducedPom>false</createDependencyReducedPom> </configuration> </execution> </executions> </plugin> </plugins> </build> </project> The Neo4j dependency is provided scope — it won't be bundled into the jar since Neo4j supplies it at runtime. The DuckDB JDBC driver is included at compile scope so it gets bundled along with its native binary. The JUnit dependency is test scope so it's excluded from the fat jar. The maven-shade-plugin builds the fat jar at package time. Confirm Maven can resolve the dependencies: Shell mvn dependency:resolve The Java Classes Create DuckDBConnection.java at src/main/java/com/example/duckdb/: Java package com.example.duckdb; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; public class DuckDBConnection { private static volatile Connection instance; private DuckDBConnection() {} public static Connection getInstance() throws SQLException { if (instance == null || instance.isClosed()) { synchronized (DuckDBConnection.class) { if (instance == null || instance.isClosed()) { instance = DriverManager.getConnection("jdbc:duckdb:"); } } } return instance; } } The volatile keyword ensures the instance is visible across threads without full synchronization on every read. The jdbc:duckdb: connection string with no path gives us an in-memory DuckDB instance. Now create DuckDBProcedure.java in the same package: Java package com.example.duckdb; import org.neo4j.procedure.Description; import org.neo4j.procedure.Name; import org.neo4j.procedure.Procedure; import java.sql.Connection; import java.sql.ResultSet; import java.sql.ResultSetMetaData; import java.sql.Statement; import java.sql.Types; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.stream.Stream; public class DuckDBProcedure { public static class QueryResult { public Map<String, Object> row; public QueryResult(Map<String, Object> row) { this.row = row; } } @Procedure(name = "duckdb.query") @Description("Run a DuckDB SQL query and return results as rows") public Stream<QueryResult> query(@Name("sql") String sql) throws Exception { List<QueryResult> results = new ArrayList<>(); synchronized (DuckDBConnection.class) { Connection conn = DuckDBConnection.getInstance(); try (Statement stmt = conn.createStatement()) { boolean hasResultSet = stmt.execute(sql); if (hasResultSet) { try (ResultSet rs = stmt.getResultSet()) { ResultSetMetaData meta = rs.getMetaData(); int columnCount = meta.getColumnCount(); while (rs.next()) { Map<String, Object> row = new HashMap<>(); for (int i = 1; i <= columnCount; i++) { String colName = meta.getColumnName(i); int colType = meta.getColumnType(i); String typeName = meta.getColumnTypeName(i); Object value = mapValue(rs, i, colType, typeName); row.put(colName, value); } results.add(new QueryResult(row)); } } } else { Map<String, Object> status = new HashMap<>(); status.put("status", "OK"); status.put("updateCount", (long) stmt.getUpdateCount()); results.add(new QueryResult(status)); } } } return results.stream(); } private Object mapValue(ResultSet rs, int index, int sqlType, String typeName) throws Exception { if (typeName != null && typeName.equalsIgnoreCase("HUGEINT")) { long val = rs.getLong(index); return rs.wasNull() ? null : val; } switch (sqlType) { case Types.TINYINT: case Types.SMALLINT: case Types.INTEGER: { long val = (long) rs.getInt(index); return rs.wasNull() ? null : val; } case Types.BIGINT: { long val = rs.getLong(index); return rs.wasNull() ? null : val; } case Types.FLOAT: case Types.REAL: case Types.DOUBLE: case Types.NUMERIC: case Types.DECIMAL: { double val = rs.getDouble(index); return rs.wasNull() ? null : val; } case Types.BOOLEAN: case Types.BIT: { boolean val = rs.getBoolean(index); return rs.wasNull() ? null : val; } case Types.NULL: return null; default: return rs.getString(index); } } } A few things worth noting about DuckDBProcedure: We use stmt.execute(sql) rather than executeQuery() because DuckDB write operations like COPY don't return a ResultSet. The boolean return value tells us which path to take.The mapValue method handles type mapping explicitly. DuckDB's HUGEINT type – used for aggregated sums — has no standard java.sql.Types mapping, so we check the type name directly. Every numeric read is followed by rs.wasNull() to avoid returning 0 for a null column.We synchronize on DuckDBConnection.class to keep concurrent Cypher calls thread-safe against the shared connection. Writing the Tests We tried neo4j-harness first, which spins up a lightweight embedded Neo4j instance in JUnit — the same approach used in the Neo4j Procedure Template. In our environment, we found that the embedded server took longer than expected to become ready during the test run, which led us to a simpler alternative. The fix is to skip neo4j-harness entirely and test the DuckDB logic directly via JDBC. Since DuckDBProcedure is a thin wrapper around a JDBC connection, we can exercise the same type mapping, null handling, and file reading logic without Neo4j in the loop at all. The tests run in under 4 seconds and are fully self-contained. First, add the test directory structure and a src/test/resources folder to hold the test data files: Shell mkdir -p src/test/java/com/example/duckdb mkdir -p src/test/resources The test data files cover all the types and edge cases we want to verify: Plain Text src/test/resources/ ├── test.csv ├── test_nulls.csv ├── test_floats.csv ├── test_booleans.csv ├── test_timezones.csv └── test.json Maven copies everything in src/test/resources to target/test-classes automatically, so the test class can resolve file paths via the classloader without hardcoding any absolute paths. The test class at src/test/java/com/example/duckdb/DuckDBProcedureTest.java covers 12 cases: Range queriesCSV readingParquet write and readJSON readingAggregation returning numeric values rather than stringsNull handlingFloatsBooleansEmpty result setsBad SQL error surfacingIn-memory table persistence across callsDuckDB-side JOIN between two files Each test mirrors something we verified manually in Neo4j. File paths are resolved portably so the tests work regardless of where the project is cloned: Java private String resourcePath(String filename) { return getClass().getClassLoader().getResource(filename).getPath(); } The Parquet test is the one exception — since DuckDB generates the Parquet file rather than reading a pre-existing one, it writes to a JUnit @TempDir that's cleaned up automatically after the test run. Run the full suite with: Shell mvn test All 12 tests should pass in a few seconds. Running mvn package will also run the tests before building the fat jar, so any regression in the DuckDB logic is caught before the jar is deployed. Building and Installing Build the fat jar: Shell mvn package -DskipTests Any warning about MANIFEST.MF from the shade plugin is harmless. The jar lands at target/neo4j-duckdb-procedure-1.0.0.jar. Copy it to your Neo4j Desktop plugins folder. You can find the path in Neo4j Desktop under the three-dot menu for your database instance, then Open Folder > Plugins. Then copy the jar: Shell cp target/neo4j-duckdb-procedure-1.0.0.jar "/path/to/your/neo4j/plugins/" Open neo4j.conf in the conf folder alongside plugins and add: Plain Text dbms.security.procedures.unrestricted=duckdb.* Restart the database. Confirm the procedure is registered using Query from the left-hand navigation pane: Cypher SHOW PROCEDURES YIELD name WHERE name STARTS WITH 'duckdb' RETURN name You should see duckdb.query. If the procedure doesn't appear after restarting, also add the following in neo4j.conf: Plain Text dbms.security.procedures.allowlist=duckdb.* Some Neo4j configurations require procedures to be explicitly allowlisted before they're loaded at startup. Putting It to Work The test data files used here are also available in src/test/resources in the project repository. Let's start simple. DuckDB has a built-in range function we can use without any external data: Cypher CALL duckdb.query("SELECT range AS num FROM range(1, 6)") YIELD row RETURN row.num AS num Five rows should be returned, confirming that DuckDB is running inside Neo4j. Note that DuckDB's range() function is exclusive of the upper bound, so range(1, 6) returns 1 through 5. Now let's read a CSV file. Create a test file at /tmp/test.csv: Plain Text city,country,population London,UK,9648110 Paris,France,2161000 Berlin,Germany,3645000 Tokyo,Japan,13960000 New York,USA,8336817 Query it from Cypher: Cypher CALL duckdb.query("SELECT city, country, population FROM read_csv_auto('/tmp/test.csv') ORDER BY population DESC") YIELD row RETURN row.city AS city, row.country AS country, row.population AS population DuckDB reads and parses the CSV; Neo4j receives the result. We can also write the CSV out as a Parquet file: Cypher CALL duckdb.query("COPY (SELECT * FROM read_csv_auto('/tmp/test.csv')) TO '/tmp/test.parquet' (FORMAT PARQUET)") YIELD row RETURN row And read it back: Cypher CALL duckdb.query("SELECT city, country, population FROM read_parquet('/tmp/test.parquet') ORDER BY population DESC") YIELD row RETURN row.city AS city, row.country AS country, row.population AS population DuckDB handles JSON just as easily. Create /tmp/test.json: JSON [ {"city": "London", "country": "UK", "population": 9648110}, {"city": "Paris", "country": "France", "population": 2161000}, {"city": "Berlin", "country": "Germany", "population": 3645000}, {"city": "Tokyo", "country": "Japan", "population": 13960000}, {"city": "New York", "country": "USA", "population": 8336817} ] And read it: Cypher CALL duckdb.query("SELECT city, country, population FROM read_json_auto('/tmp/test.json') ORDER BY population DESC") YIELD row RETURN row.city AS city, row.country AS country, row.population AS population We can also join two files entirely inside DuckDB before the result reaches Cypher. Create /tmp/test_timezones.csv: Plain Text city,timezone London,GMT Paris,CET Berlin,CET Tokyo,JST New York,EST Then join it against the population data: Cypher CALL duckdb.query("SELECT p.city, p.population, t.timezone FROM read_csv_auto('/tmp/test.csv') p JOIN read_csv_auto('/tmp/test_timezones.csv') t ON p.city = t.city ORDER BY p.population DESC") YIELD row RETURN row.city AS city, row.population AS population, row.timezone AS timezone The join happens entirely inside DuckDB. Neo4j just receives the final result set. Now for the most interesting demo: combining DuckDB analytics with Neo4j graph data in a single query. First, create some graph nodes: Cypher UNWIND [ {city: "London", continent: "Europe"}, {city: "Paris", continent: "Europe"}, {city: "Berlin", continent: "Europe"}, {city: "Tokyo", continent: "Asia"}, {city: "New York", continent: "North America"} ] AS data MERGE (c:City {name: data.city}) SET c.continent = data.continent Now join DuckDB file data with Neo4j graph data by city name: Cypher CALL duckdb.query("SELECT city, population FROM read_parquet('/tmp/test.parquet')") YIELD row MATCH (c:City {name: row.city}) RETURN c.name AS city, c.continent AS continent, row.population AS population ORDER BY row.population DESC And aggregate by continent — population figures from DuckDB, continent groupings from the graph: Cypher CALL duckdb.query("SELECT city, population FROM read_parquet('/tmp/test.parquet')") YIELD row MATCH (c:City {name: row.city}) RETURN c.continent AS continent, count(c) AS city_count, sum(row.population) AS total_population ORDER BY total_population DESC That's two engines contributing to a single result: DuckDB handles the file analytics, Neo4j handles the graph traversal and aggregation. What We Learned Building this wasn't entirely smooth, and the bumps are worth documenting. XML comments in a Maven POM can't contain double dashes anywhere except at the closing -->. That's a quirk of XML parsing that's easy to miss.DuckDB's HUGEINT type – used for the result of aggregation functions like SUM – has no corresponding entry in the standard java.sql.Types constants. Our initial type mapping switch statement silently fell through to the string default, returning numeric values as strings. The fix is to check ResultSetMetaData.getColumnTypeName() by name before entering the switch.Write operations like COPY can't go through executeQuery() since they don't return a ResultSet. Switching to stmt.execute() and checking the boolean return value handles both read and write paths cleanly.Null numeric values in JDBC return 0 from getInt() and getLong(), not null. You must call rs.wasNull() immediately after each numeric read to distinguish a genuine zero from a missing value. Comparing Approaches It's also worth noting that APOC (Awesome Procedures on Cypher) Extended's apoc.load.jdbc procedure supports DuckDB via its JDBC driver and apoc.jdbc.analytics goes further by letting you export graph data into a temporary DuckDB table for analytical queries. These are complementary approaches worth exploring alongside the custom procedure built here. The following table compares the three ways to use DuckDB with Neo4j, each with a different integration style. ApproachHow It WorksDirectionPersistent StateRequiresCustom procedure (this article)DuckDB runs in-process inside the Neo4j JVM as a singleton connectionFiles and SQL into Neo4jYes, across Cypher callsCustom Java pluginAPOC apoc.load.jdbcDuckDB treated as an external JDBC data source; driver jar dropped into plugins folderFiles and SQL into Neo4jNoAPOC Extended + DuckDB JDBC jarAPOC apoc.jdbc.analyticsGraph data exported into a temporary DuckDB table; analytical SQL runs against itGraph data into DuckDB for analyticsNoAPOC Extended + DuckDB JDBC jar Summary We started with a simple question: could DuckDB be embedded in Neo4j the way pg_duckdb embeds it in Postgres? The answer is not natively, but closer than you might expect. With two Java classes, a fat jar, and a few lines of Neo4j configuration, we built a working duckdb.query procedure that runs DuckDB SQL directly from Cypher. Along the way we handled CSV, Parquet and JSON files, DuckDB-side joins, cross-engine aggregations with Neo4j graph data and a handful of type mapping edge cases that the 12-test suite now catches automatically. There's plenty of room to grow this further. File-backed DuckDB, a proper connection pool, cleaner error handling, and possibly a maintained open-source plugin are all natural next steps. But the foundation is there. What we've built here is a proof of concept — it works, and the tests confirm it, but anyone taking it further should treat robustness, connection pooling, and error handling as open items before putting it near production data. The full source code is available on GitHub.
OpenCode is a terminal-based, open-source AI coding assistant that supports multiple providers, including GitHub Copilot. For developers looking for a vendor-independent AI coding assistant, OpenCode offers a flexible way to integrate AI into their coding workflow. Enjoy! Introduction to OpenCode There are many AI models and also many AI coding assistants. Choosing one is hard and depends on whether you run the models locally or in the cloud. When running locally, Qwen3-Coder is a strong AI model for programming tasks. In previous posts, DevoxxGenie, a JetBrains IDE plugin, was often used as an AI coding assistant. DevoxxGenie integrates well within JetBrains IDEs. But it is also a good idea to take a look at other AI coding assistants. In previous blogs, Qwen Code and Claude Code were used in combination with local models. Another post discussed GitHub Copilot CLI. GitHub Copilot CLI integrates very well with GitHub Copilot. A disadvantage of Qwen Code, Claude Code, and GitHub Copilot CLI is that these are all closed source. Another disadvantage of Claude Code and GitHub Copilot CLI is that these are quite limited concerning integration with 'other' inference engines or cloud vendors. With GitHub Copilot CLI, for example, you can only use it with GitHub Copilot. This is one area where OpenCode shines. Besides that, it is entirely open source; there is a huge list of integrations available, including GitHub Copilot. In this blog, you will take a closer look at OpenCode, how to configure it, and how to use it. The official documentation for OpenCode can be found here. Prerequisites Prerequisites for reading this blog are: Some experience with AI coding assistants;If you want to compare to DevoxxGenie, take a look at a previous post;You need access to a local inference engine (e.g., Ollama) or an account with a supported cloud vendor. How to Install OpenCode The easiest way to install OpenCode is to execute the following bash command. Do note that if you are using Windows, it is recommended to use WSL. Shell curl -fsSL https://opencode.ai/install | bash It might be that you need to source your .bashrc or start a new terminal before the opencode command is available. How to Set Up OpenCode With GitHub Copilot Open a terminal and navigate to a git repository. You can use this one if you have none available. Be sure to check out the github-copilot branch. If you want to execute the commands from this blog, you first delete the .github and src/test directories. You start OpenCode by typing the command opencode in the terminal. You will see a chat-like textbox. The first thing to do is to connect to an inference engine (local or remote). You do so by the command /connect. In the figure below, you only see a small part of the huge list. Choose GitHub Copilot. Next, you need to choose a deployment type. If you want to connect using your personal GitHub account, choose GitHub.com. If you have a GitHub Enterprise account, you choose GitHub Enterprise Data residency or self-hosted. The next steps depend on your choice of the deployment type. You will need to connect to your GitHub account and authorize OpenCode to use this account. In the last step, you will be asked to choose a default model. You always have the opportunity to change the model by means of the command /models. Running Your First OpenCode Prompt If you enter a prompt, OpenCode sends the request to the model provider, which then generates the response. Note that the context menu is now much larger than when you first started OpenCode. In the top right of the terminal window, you can see how many tokens were used, how much of the context window has been used, and how much it cost you. Configuring OpenCode Agents, Skills, and MCP Servers Just like many other AI coding assistants, you can extend the capabilities of OpenCode. OpenCode can be extended with agents. The configuration needs to be located in ~/.config/opencode/agents. You can invoke the agents by command /agents. OpenCode can be extended with skills. OpenCode will search many paths for available skills, but the most convenient one and aligned with the agents configuration is ~/.config/opencode/skills/. With MCP servers, you can add all kinds of tools or integrations with other tools (git repository manager, Jira, CI/CD applications, etc.) to OpenCode. The configuration is located in ~/.config/opencode/opencode.jsonc. OpenCode vs. GitHub Copilot CLI Maybe the most important question: why OpenCode? I have investigated and explored many AI coding assistants, so what are the advantages/disadvantages of all these coding assistants? My setup is IntelliJ IDEA, and I must be able to use local models (via a local Ollama instance) and GitHub Copilot. I also want to be able to configure the AI assistant with agents, skills, and MCP servers. This list is not complete, but gives you a direction of what you might expect from a coding assistant. Until now, I was pretty satisfied with GitHub Copilot CLI, but configuring it with MCP servers has been dramatic. Especially when a company-managed MCP registry is used. Bottom line, you cannot use it at the time of writing. Company-managed MCP servers are always blocked. I tried the GitHub Copilot IntelliJ plugin also. It seems to work better, but some MCP servers still don't work. They do work in OpenCode. Setting up OpenCode, including agents, skills, and MCP servers, took me less than half an hour. Compared to the time I lost trying to set MCP servers up with GitHub Copilot CLI and the IntelliJ plugin, which took me several hours and, in the end, was not entirely functional. Conclusion OpenCode will be my go-to AI coding assistant for the time being. It satisfies all my requirements, and it just works.
This is the first of eight articles about how web apps work on iOS, and it has less code than the rest. Here I look at where the oddities come from, the ones everyone who ships a PWA to the iPhone ends up living with. Open the <head> of almost any PWA built in 2026 and next to the manifest link you'll most likely find the apple-mobile-web-app-capable meta tag. Safari's old reference says it plainly: "Available in iOS 2.1 and later." Version 2.1 came out on September 12, 2008, when the App Store had already been running for two months. So full-screen web apps on the iPhone showed up after Apple had turned to the native SDK, even though the story is usually told the other way round. PWA behavior on the iPhone is full of small details like this. Behind the icon on your Home Screen are twenty-five years of attempts to turn a web page into a program, and plenty of the decisions made along the way are still sitting in your code. When Windows Trusted HTML as It Trusted Itself In 1999, together with Internet Explorer 5, Microsoft released HTML Applications, or HTA. For its time, the idea was very clear: HTML, CSS, and scripts can already draw an interface, so why not launch such a page in its own window like an ordinary Windows program, with no address bar and no browser buttons? A file with the .hta extension was opened by a system utility, mshta.exe, which took the IE engine but left its security model outside the door. Microsoft wrote about this in its documentation without a trace of embarrassment. HTAs are called "full-fledged applications" that "are trusted," and the summary reads: "an HTA runs like any executable (.exe) file written in C++ or Visual Basic." The user was asked once, before the download, whether to save the file or run it. After that, the app could do everything an ordinary program can: write to disk, start processes, read the registry. It looked roughly like this: HTML <html> <head> <title>Inventory</title> <hta:application applicationname="Inventory" border="thin" showintaskbar="yes" /> <script language="JScript"> function openLog() { var shell = new ActiveXObject("WScript.Shell"); shell.Run("notepad.exe C:\\inventory\\log.txt"); } </script> </head> <body> <button onclick="openLog()">Open log</button> </body> </html> No permission dialog, no origin, no sandbox: the WScript.Shell object runs anything with the current user's rights. For an internal admin tool in the late nineties that was convenient, sysadmins wrote setup wizards, support dashboards, and utilities in HTA that otherwise would have had to be built in Visual Basic. All of it ran only on Windows and only on the IE engine, so HTA couldn't boast the cross-platform reach that people usually pick the web for. Microsoft even patented the approach: the application "Method and apparatus for writing a Windows application in HTML" was filed on May 20, 1999, and patent US 6,662,341 was granted on December 9, 2003. Why .hta Is Still Stripped from Email Attachments The model of "download some markup, get the rights of a program" aged very badly. In MITRE ATT&CK, mshta.exe has a technique of its own, T1218.005, which describes how attackers run malicious .hta files, JavaScript and VBScript through the utility as early as the initial-access stage. A trusted system binary is handy because it slips past policies that only watch for unfamiliar executables. The LOLBAS project, which catalogs legitimate Windows programs that can be abused in attacks, lists mshta.exe with paths in System32 and SysWOW64 all the way up to Windows 11. The loudest episode happened in April 2017. FireEye described attacks through RTF documents for Microsoft Office: inside sat an OLE object with a link; Word went to the attacker's server on opening, got an HTA from there, and handed it to a handler that executed the file. The vulnerability was assigned CVE-2017-0199, and Microsoft fixed it in the update of April 11, 2017. In November 2021, CISA added it to its catalog of vulnerabilities exploited in real attacks. HTA itself wasn't the bug in that chain. It was just the full trust the attackers were after. Today Outlook blocks .hta attachments by default, on the same list as .exe and .bat, and I can't think of a better epitaph for the technology. What do PWAs have to do with it? Well, they look like apps, open from an icon and work offline, but they stay inside the browser security model, with its origins, HTTPS, sandbox, and permissions the user grants one at a time. When, in the next parts, a Service Worker refuses to register over plain HTTP, or iOS won't let a web app near Safari's data, remember mshta.exe: that's exactly the kind of neighbor browsers don't want to see next door anymore. June 2007: No SDK Needed On January 9, 2007, at Macworld, Steve Jobs showed the iPhone and said nothing about third-party apps. Today that looks odd, but back then the phone was sold as a finished product with mail, a browser, maps and an iPod inside, and the question of a platform simply never came up on stage. Developers got their answer on June 11 at WWDC. According to a transcript that 9to5Mac published later, Jobs said the iPhone has "the full Safari engine" inside, so you can write Web 2.0 and Ajax apps that "look exactly and behave exactly like apps on the iPhone," and then added: "There's no SDK that you need!" Apple's press release from the same day says the same thing in a calmer voice. Web apps would look and behave like built-in ones, and they could make a call, send an email, and drop a pin in Google Maps. They would extend what the iPhone can do "without compromising its reliability or security." And they could be updated by "simply changing the code on the developers' own servers." It sounds almost like a definition of a PWA, but in June 2007 there wasn't much behind those words. The app lived in a Safari tab, you reached it through a link or from your bookmarks, and all the integration with the phone came down to tel: and mailto: links plus addresses that opened in Maps. There was no camera API, no background work, and no storage in today's sense. There was no Home Screen icon at all: the Home Screen itself was fixed, and without a jailbreak you couldn't rearrange anything on it. Developers took to this model anyway, and with real enthusiasm. At iPhoneDevCamp in July 2007, Wired reported, the person everybody was talking about was Joe Hewitt, one of the creators of Firefox and Firebug. He had put together iUI, a set of templates that made a site on the iPhone look and act almost like a built-in app. Many developers had already adopted the templates as a base, and Wired wrote that they were clearly becoming the default look of the iPhone web. That August, Hewitt, by then at Facebook, built a version of the social network specifically for the iPhone, and it was a web app too: the native client didn't come out until the summer of 2008. To me, this model still feels more honest than the way it was later retold. The web didn't pretend to be deeply integrated with the phone. It asked the system to perform a system action, and that habit survived all the way to Web Share, and to the way an installed web app on iOS now asks for permission to send notifications. An Icon in January, an SDK in March The button this whole series exists for arrived with iPhone OS 1.1.3 on January 15, 2008. TidBITS described the novelty in detail at the time: Safari got a plus button, its menu had an Add to Home Screen item, and an icon appeared on the Home Screen that Apple called a Web Clip. The same release let you edit the Home Screen for the first time, icons started to wiggle under your finger, and the number of screens went up to nine. Apple's documentation has said the same thing ever since: "The Web Clip feature is available in iOS 1.1.3 and later." The apple-touch-icon was born along with the icon. A site could drop a PNG in the root or point to one in a <link>, and if it did neither, iOS went looking in the root for a file called apple-touch-icon.png. Later versions also tried variants like apple-touch-icon-57x57-precomposed.png. The -precomposed suffix, which has worked since iOS 2.0, was simply a request: Safari, please don't add your own effects to this icon. The era of glossy highlights is long gone, yet I still see requests for these files in server logs, on servers where nobody ever put them. Still, an icon added this way opened an ordinary Safari tab with all the toolbars. The first Web Clip was a bookmark moved to the Home Screen, and nothing more. Apple, meanwhile, had already changed its mind. Back on October 17, 2007, a short letter from Jobs appeared on the company's site. It began with the words "Let me just say it: We want native third-party applications on the iPhone" and promised an SDK in developers' hands in February. He blamed the delay on security: Apple was trying to give developers an open platform and protect users from viruses and malware at the same time, and he pointed to Nokia, which on some of its new phones allowed only apps carrying a digital signature from a known developer. Four months passed between the June "no SDK needed" and this letter. The SDK missed February. It was shown on March 6, 2008, together with the App Store, and the press release from that day contains a phrase that set the course for the next fifteen years: third-party apps "must be approved by Apple and will be available exclusively through the App Store." The store itself opened on July 10, 2008, through iTunes, and by the time the iPhone 3G went on sale, Apple was promising more than 500 native apps in it. iPhone OS 2.0, with the App Store built in, came out the next day, July 11. Full-Screen Mode Came After the App Store Now the detail I started with. The apple-mobile-web-app-capable meta tag hides Safari's interface and opens a site from its icon in full screen. Its neighbor, apple-mobile-web-app-status-bar-style, controls the strip with the clock. Apple marked both as available since iOS 2.1, and that version came out on September 12, 2008. The same reference describes the navigator.standalone property, which lets a page tell that it was launched from the Home Screen. The apple-touch-startup-image splash picture was added only in iPhone OS 3.0, and icons for different screen resolutions in 4.2. So Apple was still finishing the web app mode when the native platform was already on sale. There is no "first they bet on the web, then dropped it" in this timeline. Web apps got modest, minimal support and lived on it for almost ten years. What iOS kept from that period is a set you can still find in old projects: a separate meta tag for every capability, no general description of the app, and installation only by hand, through the Safari menu. A lot of projects have dragged the launch-mode check along since those days, and it usually looks like this: JavaScript const standalone = window.navigator.standalone === true || window.matchMedia('(display-mode: standalone)').matches; if (standalone) { document.documentElement.classList.add('launched-from-home'); } The first half of the condition is a legacy of iPhone OS 2.1, and the second arrived with the web manifest. On the iPhone, this check usually fails not because of the code but because of expectations: navigator.standalone exists only in WebKit on iOS, while the value of display-mode depends on exactly how the site was added to the Home Screen and what was in the manifest at install time. We'll break down how this works in Part 4 of this series. Ten Years on Meta Tags and AppCache Between 2008 and 2018, web apps on the iPhone stood on two things: those same Apple tags and the Application Cache. The tags were simple and tedious. For the splash screen, you had to draw a separate image for almost every iPhone and iPad screen size, in the right orientation and with the right media query. I've come across repositories where the folder of splash images weighed more than the app itself, with a comment sitting next to it along the lines of "don't delete, white screen on iPad otherwise." Offline was worse, and its roots, by the way, aren't Apple's. On May 31, 2007, less than two weeks before WWDC, Google released Gears, a browser plugin that gave pages a local SQLite database, background workers, and a LocalServer module that could serve saved HTML, scripts, and images without a network. Gears was what powered offline access in Gmail. On February 19, 2010, the Gears team wrote on its blog that its effort was shifting toward getting all of Gears' capabilities into web standards like HTML5, and that the plugin itself would get no new features. For the history of PWAs this is a telling moment: a browser vendor retired its own proprietary technology in favor of a standard, and Service Worker would later travel roughly the same road. The standard heir to LocalServer was AppCache. You declared a text file listing your resources in <html manifest="...">, and the browser promised to open the page without a network. Safari on the iPhone supported it, and for web apps launched from the Home Screen it was for a long time the only standard way to open without a connection instead of showing a "You are not connected to the Internet" error page. A typical manifest looked harmless: HTML CACHE MANIFEST # v12 CACHE: /index.html /app.js /styles.css NETWORK: /api/ FALLBACK: / /offline.html The catch was in the update rules. Apple's documentation warned outright that changing the HTML file does nothing until the manifest itself changes, so everybody kept a comment with a version number in it and remembered to bump it. If the manifest did change, the new cache was built in the background and applied only on the next visit, which meant the user saw the old version first and the new one only after a restart. If a single file from the list came back with an error, the update wasn't applied at all. Some users could get stuck on an old bundle for a long time without even knowing it. In May 2012, Jake Archibald, who was then building Lanyrd's mobile site on AppCache, published an article in A List Apart with a famously rude title: "Application Cache is a Douchebag." He went through all of these traps, and if I put it in my own words, AppCache looked like an offline mode but behaved like a hidden deploy that you don't control. Funnily enough, Archibald later ended up among the editors of the Service Worker specification, along with Alex Russell, who comes up below. Service Worker grew straight out of that grievance. Instead of a declarative list, you got a programmable fetch handler, an explicit lifecycle, and a Cache API where you decide for yourself what to store and when. If a legacy project still has <html manifest="cache.appcache"> sitting in it, don't try to migrate gradually by adding a worker alongside. Two offline mechanisms on one origin fight over the same page, and the bug then looks like "some users have the old version" even though git is perfectly clean. Remove AppCache, ship the worker, and check a cold start on a real device. How Apple Campaigned for HTML5 On April 29, 2010, Apple published Jobs's open letter "Thoughts on Flash," explaining why Flash wouldn't be coming to the iPhone, iPod and iPad. Most of the text is about battery life, stability, and the touch interface, but the most interesting part is how it talks about the web. "Rather than use Flash, Apple has adopted HTML5, CSS and JavaScript," Jobs writes, meaning open standards that its devices support quickly and efficiently. The letter ends with a forecast: "New open standards created in the mobile era, such as HTML5, will win on mobile devices (and PCs too)." There's also a line that's hard to read today with a straight face. Adobe, according to Jobs, claimed that Apple's decision was driven by business and a wish to protect the App Store, "but in reality it is based on technology issues." On Flash, I think, he turned out to be right in the end. But the forecast of HTML5's victory came from a company whose Home Screen web apps couldn't send notifications and relied on that very AppCache for offline. Safari was one of the best mobile browsers at the time, and WebKit inside it really did push standards forward, while the app mode stayed roughly where it had been left in 2008. Developers saw the contradiction. In November 2009, that same Joe Hewitt, by then the author, by TechCrunch's estimate, of the most popular iPhone app ever, handed the native Facebook client over to another engineer. He told the publication that the decision was entirely about Apple's policies and that he was fundamentally opposed to the review process. His explanation ended like this: "The web is still unrestricted and free, and so I am returning to my roots as a web developer." I think this contrast explains a whole decade better than any conspiracy theory. Apple was happy to promote the web as a technology for pages and much more careful about the web as an app platform on its own phone. At least that's how it looks from the outside, since the company doesn't publish its motives for each missing API. The most popular explanation for the pause comes down to the App Store commission. You can't prove that kind of causality from public sources, and I wouldn't build technical decisions on it. The engineering explanation is duller, but it can be checked: background execution, persistent storage, and notifications for websites sit badly with a phone's battery, memory, and permission model, and WebKit on iOS has to keep compatibility for years with everything it has already shipped. The commercial incentives don't go away because of that. They just don't explain why, for example, cache.addAll fails entirely because of one broken URL. Who Else Tried to Turn the Web into a Platform? Apple wasn't the only one in those years to look at HTML as a language for apps, and almost all of these attempts ended badly. They're worth remembering, if only so you don't repeat old arguments in PWA debates. Palm showed the Pre smartphone at CES in January 2009, and on June 6 it went on sale through Sprint. The webOS operating system was daring by design: the interface was built on WebKit, and third-party apps were written in HTML, CSS, and JavaScript with the Mojo framework. The Verge, in a long piece on Palm's history, quoted one of its sources explaining the choice: instead of keeping a person around whose job is writing code to center text, you can "stand on the shoulders of giants and let WebKit do it." According to the same piece, Palm ported the V8 engine for speed and was the first to ship it on a mobile device. The rest of the story is short. In 2010, HP bought Palm; on August 18, 2011, it announced it would stop making webOS devices, and in February 2013 it sold the system to LG, where it still lives on in TVs. Google came at it from a different side. The Chrome Web Store opened on December 7, 2010, and besides extensions, it distributed Chrome Apps, packaged and hosted apps that ran in Chrome almost like desktop programs. On August 19, 2016, the Chromium blog published "From Chrome Apps to the Web": packaged apps on Windows, Mac, and Linux were actively used by about 1% of users. Google announced that it would remove them from those platforms and told developers to move to Progressive Web Apps with Service Workers and Web Push. By mid-December 2017, Chrome Apps had vanished from search and the Chrome Web Store directory, although installed apps on those systems kept launching for a while. The final shutdown dates were pushed back several times after that, but the direction never changed again. Mozilla went further than anyone and built a whole operating system in which an app is a web page. The first commercial Firefox OS phone, the ZTE Open, went on sale through Telefónica in Spain on July 2, 2013, for about 90 dollars, aimed at emerging markets. On December 8, 2015, Mozilla announced it was ending development and sales of Firefox OS smartphones, and gave a fairly honest reason: "we weren't able to offer the best user experience possible." In the same statement, Mozilla wrote, not without pride, that Firefox OS had proved the flexibility of the web on devices ranging from cheap smartphones to HD TVs. In September 2016, commercial development stopped altogether, but the code didn't disappear. A fork grew into KaiOS, which ran feature phones such as India's JioPhone. Microsoft, in Windows 8, which went on sale on October 26, 2012, made HTML5 and JavaScript first-class languages for Windows Store apps, right alongside C# and C++. For that, there was the WinJS library, which was open-sourced under the Apache license in April 2014. The last commit to its GitHub repository is dated May 2020. Of the four, webOS survived most visibly, and even that moved into TVs, while Firefox OS carried on in someone else's fork for feature phones. Put these stories together, and the conclusion is an unpleasant one for people who love grand plans. The web as an app language lost every time it needed its own operating system, its own store, or an ecosystem it had to build from scratch. PWAs survived because they ask for none of that and ride inside a browser the user already has. That is exactly what makes iOS a special case. The browser on the iPhone belongs to the same company as the platform with the store, and every browser there has to run on WebKit (apart from regulated exceptions in the EU), so the pace at which PWAs gain new capabilities is set by Apple. WebKit and the DMA get a full treatment in Part 3 of this series. A Name for Something That Already Existed On June 15, 2015, Alex Russell of the Chrome team published "Progressive Web Apps: Escaping Tabs Without Losing Our Soul." He writes that the evening before, over dinner, he and the designer Frances Berriman had listed the properties of a new class of apps: they adapt to any screen, work offline thanks to Service Worker, are always fresh, are served only over TLS, can be found by search engines, can be put on the home screen, and open through an ordinary link. Berriman called them Progressive Open Web Apps. In the body of the post, they shrink to Progressive Apps, while the headline carries the combination that ended up sticking. My favorite line from it says that such apps aren't packaged or distributed through stores because "they're just websites that took all the right vitamins." The same post has a detail worth reading twice if you care about iOS. Russell describes a scenario using the Chrome Dev Summit site: at first it's an ordinary tab, and on the second, third, or fourth visit, once the browser is confident the site is used regularly, it shows an install prompt on its own, filled in with data from the manifest. Safari still has no such prompt, and that's exactly why so many PWAs for the iPhone have a homemade hint somewhere in the interface, with an arrow pointing at the Share button. The word "progressive" comes from the term progressive enhancement. A basic site works in any browser, and wherever Service Workers, a manifest, and push are available, offline support, an icon, and notifications get layered on top. Installation stays optional. If your app shows a blank screen without JavaScript and counts as broken without a registered Service Worker, you've built the exact opposite of what the authors of the term had in mind. That will show up first on an old corporate iPad sitting behind a temperamental proxy. The first big examples appeared quickly, and nearly all of them were about Android. On November 10, 2015, Flipkart engineer Aditya Punjani described how Flipkart Lite was built, the mobile site of India's largest online store: a single-page React app, HTML shells served from the Service Worker cache, offline browsing of recently viewed products, and notifications through Web Push. On April 6, 2017, Twitter announced Twitter Lite at mobile.twitter.com and promised it would take up less than a megabyte on the device. The same announcement has a caveat that deserves a careful read: notifications and offline worked "in Google Chrome and other modern browsers on Android devices." On the iPhone in 2017, both of them were just a fast website, because Service Worker didn't exist in Safari yet. Cupertino, We Have a Problem! iOS 11.3 came out on March 29, 2018, and Apple's press release about it talks about new ARKit features, Animoji, health records in the Health app, and the battery health screen. Not a word about Service Workers. It turned up in the release notes for Safari 11.1, which shipped with that iOS version: support for Service Workers with debugging in Web Inspector, the Cache API, the Web App Manifest for sites added through Add to Home Screen, and Payment Request for paying with Apple Pay. The WebKit overview separately notes that Service Workers are partitioned by the origin of the top-level document, so they can't be used to track a user across sites, and the implementation details are described in the post "Workers at Your Service." The word "support" has to be read very narrowly here. Maximiliano Firtman, who had already written a piece during the beta under the headline "Cupertino, we have a problem," listed right after the release what had been left out. There was no install prompt, so the user had to open the Share menu themselves and find the Add to Home Screen item. There were no push notifications. iOS ignored the icons from the manifest and took apple-touch-icon, and without one it built an icon from a screenshot of the page. There was no splash screen, most of the colors from the manifest were dropped too, and when you left the app its state wasn't preserved: you stepped out to check an SMS code, came back, and the PWA started from scratch. Even Uber's careful PWA threw the user out into Safari at the login step, after which nothing was left of the app mode but the name. Apple's decision makes engineering sense to me. The company switched on the standards it knew how to support inside its own process and under its own battery limits, and it didn't copy Chrome's behavior on Android. For people who had already shipped a PWA on Android and expected identical behavior on both platforms, it looked like mockery, and I can see why they felt that way. What Came Later After that, the story moved in fits and starts. Push for web apps added to the Home Screen arrived only in iOS and iPadOS 16.4, in March 2023, and WebKit's announcement immediately spelled out the conditions: notifications are available only after installation, and permission is requested in response to a user action. The same release brought the Badging API and the ability to add sites to the Home Screen from third-party browsers. Declarative Web Push, which no longer needs an installed Service Worker to show a notification, was added in iOS 18.4 in March 2025. In iOS 26, a site added to the Home Screen opens as a web app by default, and a toggle called "Open as Web App" controls that. I'll say more about storage, because that's where the most outdated advice has piled up. The figure of 50 MB came from write-ups in 2018 and stopped describing reality long ago. The "seven-day rule" appeared in March 2020, when Intelligent Tracking Prevention in Safari started wiping script-writable storage for sites the user hadn't interacted with in seven days of browser use. In the same post, WebKit clarified that web apps on the Home Screen have their own counter of days of use, and their own data shouldn't be deleted in normal operation. The system can still evict it if the device runs out of space, and Apple doesn't name a single public quota figure that covers every case. I draw one practical conclusion from all this. The server stays the source of truth, local storage is a cache that may vanish one day, and a cold start after such a wipe needs to be tested on a real phone. Five years between Service Worker and push meant one simple thing for a product: if your app needed notifications, until spring 2023 native was your only option on the iPhone, however good the PWA turned out. Why an Engineer Needs All This in 2026 The history is here because it's useful for the job. It helps you avoid mistaking an incompatibility for a bug in your own app, and the typical case goes like this: a team writes a correct manifest, checks it in Chrome DevTools, and on the iPhone sees an old icon or a screenshot in its place. Someone who knows only the manifest spec starts hunting for a mistake in the JSON. Someone who remembers the Web Clip looks first at apple-touch-icon, at the way the app was installed, and at the fact that the icon was saved on the device at the moment it was added and most likely won't update by itself. The minimal <head> that I put even into internal admin tools grows out of that same past: HTML <link rel="manifest" href="/manifest.webmanifest"> <meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-status-bar-style" content="default"> <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png"> On recent iOS versions, the apple-mobile-web-app-capable tag simply repeats display: standalone from the manifest, but on a line of tablets that go years without updates, that duplicate is cheaper than working out why the app opened in a tab. Make the icon an opaque 180×180 PNG in a separate file: the system isn't obliged to use a tab favicon for the Home Screen. Another benefit is that it pays to break the word PWA into parts before you even size up the task. On the iPhone, it hides three separate capabilities, and they arrived in different years. The icon and launching without a browser UI came in 2008 along with the meta tags; solid offline support became possible in 2018 with Service Workers, and notifications showed up in 2023, only for installed apps. An internal CRM often gets by with the first. A warehouse app needs the second, plus a queue of operations: iOS still has no Background Sync, so data will only go to the server the next time the app is opened. A messenger needs the third, and this is exactly where the web on the iPhone faces its tightest limits. This is also where the habit of naming a minimum iOS version comes from. "We support iOS" isn't a requirement: iOS 15, 16.4 and 18.4 are very different from one another. In 15, web apps have no push at all; 16.4 brought standard Web Push, and 18.4 added Declarative Web Push on top. Besides, in corporate environments old iPads stay in service much longer than the product team assumes. So I check not a list of APIs but scenarios on a real device: API names say little about how things actually behave in practice. The app should install with the right name and icon and open without the browser bar. After a full close, it should launch with no network; after sign-out, it shouldn't leave the previous user's data in the cache; and it should ask for notification permission only after installation and on a button tap. Lighthouse can do what it's good at, but it won't walk through the Web Clip install path for you (Apple calls that Home Screen icon a Web Clip). Another source of confusion comes from the 2016-2018 Android articles with their beforeinstallprompt, install banners and Google Play packaging. There is no such event on the iPhone, so a snippet that triggers a "native install dialog" is useless for Safari. I'd keep recipes like that in a wiki marked "Chromium only". Otherwise, sooner or later someone will carry them into the shared bundle and spend a week figuring out why the event doesn't fire on the iPhone, when it isn't supposed to. In requirements, instead of "make a PWA," I write three lines: the minimum iOS version, whether push is needed, and what has to work offline. If "sync in the background" shows up in the last line, it's better to end the "a PWA will do" conversation before anyone estimates in story points. And if everything adds up, the actual engineering begins. In Part 2 of this series, we'll put together a minimal setup of HTTPS, a manifest, and a Service Worker, and look at why updating without App Store review works in both directions: you can ship a broken sw.js in a minute, and in that same minute break the app for all your users at once.
Since the launch of OwlVigil, I have been contemplating a key objective: fully unlocking its capabilities for developers, enabling seamless integration of AI management functionalities, and fostering the creation of innovative products built on top of these capabilities. We have developed a comprehensive set of APIs and SDKs for various programming languages, making nearly all of OwlVigil’s core capabilities available. Developers can use a subset of these features via the SDKs, or build a comprehensive AI gateway and management platform on our APIs. Today, we are officially open-sourcing the first OwlVigil Software Development Kit (SDK) — the Go SDK (https://github.com/Syrovex/owlvigil_sdk_go). Subsequently, we will implement versions for Java, Node.js, and Python. This aligns with my vision for founding Syrovex and developing OwlVigil: providing enterprises and developers with foundational capabilities for AI management. These capabilities include handling complex yet essential tasks such as model integration, cost management, access control, security compliance, and observability. However, for most developers, the complexity of the integration process can still pose a significant challenge, regardless of the platform’s feature richness. This is particularly relevant for enterprise systems; once deployed in a production environment, the considerations extend far beyond the initial success of “successfully calling a model once.” Therefore, I have always wanted OwlVigil to be “lightweight” enough to integrate seamlessly into enterprises’ existing technical ecosystems. Enterprises are not required to begin development from the beginning, nor do they need to redevelop existing applications simply to introduce an AI governance platform. Developers can continue using their familiar languages, frameworks, and models; they simply need to integrate OwlVigil into their existing call chain to gradually gain capabilities such as multi-model provider integration, cost management, access control, log monitoring, and security compliance. Our objective is straightforward: liberate enterprises and developers so they can prioritize their core business. OwlVigil: An AI Gateway and Governance Platform When people first encounter OwlVigil, they may see it as an AI Gateway or a “relay station” that forwards model requests. This understanding is incomplete. An AI Gateway is indeed part of OwlVigil. Rather than starting from scratch, we leverage established open-source technologies and industry standards to ensure a seamless integration of these capabilities. This is because simply forwarding a model request is not the most difficult part. The real complexity lies in how enterprises should manage models, providers, permissions, budgets, policies, logs, and audits once AI enters the production environment. This is the problem that OwlVigil aims to solve. In our design, OwlVigil strategically positions itself between enterprise applications, workflows, agents, and various model providers. Applications are only required to submit models and requests; they are not required to maintain the keys and interface differences for each provider within their business code. Meanwhile, enterprises can determine behind the scenes whether to permit requests, where to route them, how much budget they can consume, and how to trace issues when they arise. From an API perspective, we have divided these capabilities into two independent components: the Gateway Runtime API and the Management API. These are not merely two features of OwlVigil, but rather two API surfaces designed for different scenarios with distinct permission boundaries. Gateway Runtime API for AI Model Requests The Gateway Runtime API is designed for applications and agents and is responsible for processing actual model requests. It currently supports model queries, chat completions, responses, embeddings, streaming, and Anthropic-compatible messages. Developers will find a relatively simple and familiar model interface. Before a request reaches the upstream Provider, OwlVigil handles authentication, permission checks, model routing, policy enforcement, budget verification, and access restrictions. After the request completes, OwlVigil also logs tokens, costs, status, logs, and the call chain. In summary, it addresses the following aspects: first, the “how to invoke a model”; second, the control of the process before invocation; third, the routing of requests during invocation; and fourth, the monitoring and auditing after invocation. Management API: Making AI Governance Capabilities Available to Developers The Management API is responsible for controlling and managing OwlVigil. This tool empowers developers to automate tasks that previously required manual execution in the Dashboard, including: The following actions are required to create and isolate different workspaces and runtime environments: Configuring providers, models, routing, and fallback candidatesCreating, rotating, limiting, and revoking gateway keysManaging teams, members, roles, and access permissionsConfiguring request policies, budgets, quotas, and cost alertsQuerying usage, request logs, audit logs, and invocation tracesManaging webhooks, event subscriptions, retries, and redeliveryIntegrating with subscription, billing, top-up, order, and account workflows These capabilities may be especially crucial for internal enterprise platforms, automation systems, and SaaS products, perhaps even more so than simple model calls. For example, enterprises can automatically generate gateway keys and budgets for different teams within internal developer platforms. SaaS products can establish independent workspaces, permissions, and usage quotas for each customer. And operations systems can monitor abnormal requests and cost fluctuations through logs, traces, and webhooks. How the OwlVigil SDK Simplifies API Integration The SDK is built on top of these two sets of APIs, addressing many engineering challenges, including typed requests, authentication, timeouts, streaming, pagination, request metadata, structured errors, retries, and Webhook signature verification. While these capabilities are not complex on their own, implementing them from scratch would be a waste of time and risk introducing security and reliability issues in production environments. The Software Development Kit (SDK) unifies the handling of these common capabilities, enabling developers to build their own applications, internal enterprise AI platforms, and even customer-facing AI management products directly on top of OwlVigil. For a more comprehensive overview of capabilities and usage, please visit the OwlVigil Developer Center and Core Concepts. This section is exclusively designed to provide readers with instructions on installation, making calls, and accessing documentation. It does not reiterate the product capabilities previously covered. The following revisions have been made: Getting Started With the OwlVigil Go SDK The OwlVigil Go SDK is open-source and can be installed directly via Go Modules: Go go get github.com/Syrovex/owlvigil_sdk_go Once installed, create a Client using the Gateway Key to call the OwlVigil Gateway. Below is an example of querying the models available in the current Workspace: Go client := gateway.NewClient( owlvigil.WithAPIKey( os.Getenv("OWLVIGIL_GATEWAY_KEY"), ), ) Go models, meta, err := client.ListModels( context.Background(), ) if err != nil { log.Fatal(err) } Go fmt.Println(meta.RequestID, len(models.Data)) If you need to call the Management API, you can use management.NewClientand pass in OWLVIGIL_API_KEY: Go client := management.NewClient( owlvigil.WithAPIKey( os.Getenv("OWLVIGIL_API_KEY"), ), ) The Gateway Key and the Management API Key are separate sets of credentials. The former is used for applications to call models, while the latter is used to manage resources such as workspaces, routing, members, policies, and budgets. It is important to note that these two functions should not be used interchangeably. In production environments, please refrain from hardcoding them in source code or committing them to a Git repository. The repository already provides ready-to-run examples for model invocation, streaming, Gateway Keys, usage queries, team management, webhooks, and multi-environment configuration. These can be referenced directly based on the specific use cases of the user. Please refer to the following resources for more information: Go SDK DocumentationGitHub Open-Source Repository Upcoming OwlVigil SDKs for Java, Node.js, and Python The Go SDK represents our inaugural release, and we have more planned for the future. The next step in our company’s strategic plan is the gradual release of Java, Node.js, and Python SDKs. These will cover the most common development languages and scenarios found in backend services, AI applications, data processing, and enterprise systems. The Software Development Kit (SDK) is a living, breathing project that never truly ends once it is released. In addition to continuously integrating new OwlVigil capabilities, we will also enhance the sample projects, development guides, and Recipes. The Developer Center, Quickstart, API Reference, and Go SDK documentation are currently accessible at https://owlvigil.com/developer/.If you are developing AI applications, agents, or internal enterprise tools, or if you wish to integrate model routing, budgeting, permissions, and auditing capabilities into your own systems, we encourage you to try it out. We will continue to enhance the project. Should you encounter any issues while using it, or have suggestions regarding existing features or future SDKs, please feel free to contact us directly via a GitHub issue or other channels. This article was originally published on my blog. For the latest version and future updates, please visit the original post: https://jaketao.com/language/en/owlvigil-open-source-go-sdk/.
When developers hear Model Context Protocol (MCP), the mental picture is usually a server. An MCP server exposes tools or resources. An AI agent connects to it and suddenly gains access to a database, a source-code repository, a documentation system, a browser, or some corporate service. That is an important use case for MCP, but I think it hides another one that may eventually become much more visible to ordinary users: Desktop applications can expose themselves through MCP. The application does not need to contain an LLM. It does not need to implement an agent. It does not need its own chat interface. It only needs to expose the right capabilities. The agent may already be sitting somewhere else. I recently implemented this idea in Diptych, a macOS file manager, and the experience changed the way I think about MCP. Diptych in a Nutshell Diptych is an open-source, native macOS two-pane file manager written in Swift and SwiftUI. The concept is deliberately traditional: two directories side by side, keyboard-oriented navigation, copying and moving between panes, rename, permissions, Quick Look, directory comparison, text and binary editing, undo, and the other operations expected from this style of file manager. The project is available on GitHub: https://github.com/verhas/diptych. But this article is not really about Diptych as a file manager. It is about what happened when I gave an ordinary personal productivity application an MCP interface. We Usually Think of MCP as a Server Technically, Diptych does contain an MCP server. It listens locally and provides an interface through which an MCP-capable agent can interact with the running application. But describing it simply as an "MCP server" misses the interesting part. Diptych is not a server application. It is not a multi-user service sitting somewhere on a network waiting for clients. It is an ordinary application used by one person on their Mac. You launch it. You browse directories. You select files. You open windows. You compare directories. You copy, rename, and move things. MCP is simply another interface to that same running application. Through MCP, an agent can ask questions such as: What windows are open?Which directories are displayed in the two panes?Which pane is active?What files are selected?What is currently being compared?What options were used for the comparison? It can also interact with the application: change the active pane, select files, open comparison or information windows, change selected settings, and propose batches of file operations. This is MCP used as a personal productivity interface. But those MCP functions are implementation details. They are not what makes the feature interesting to the user. What matters is what I can now ask my agent to do. For example: Select the three largest archive files that are older than one week and have not been accessed for at least three days. I can do this manually in a file manager, of course. I can sort files, check their sizes and timestamps, identify archives, and select the appropriate files. Or I can express my intent in one sentence. I do not even have to specify that an archive might mean .zip, .tar.gz, .7z, or some other format. The LLM already understands what an archive is. Through MCP, it knows which directory I am working in; it can inspect the files and their metadata, reason about size, modification, and access times, and tell Diptych which files to select. The result is still visible in the ordinary Diptych GUI. I can see exactly what the agent selected before doing anything with those files. I can go one step further: Select the three largest archive files older than one week that have not been accessed for at least three days, and delete them. Now the request has a destructive consequence, so the interaction changes. The agent can determine which files satisfy the request, but it does not silently delete them. Through MCP, it proposes the deletion operations to Diptych. Diptych presents its normal confirmation window with the concrete files about to be deleted. I inspect the list and approve it. (Or not.) Only then does Diptych perform the deletion. This illustrates the division of responsibilities particularly well: the LLM understands what I mean, MCP connects that understanding to the application, and Diptych remains in control of what actually happens to my files. And I think that use case deserves much more attention. Do Not Put the Agent Into Every Application When adding AI functionality to an application, the obvious architecture is something like this: Plain Text Application ├── UI ├── Business Logic ├── LLM API ├── Agent Loop ├── Tool Calling └── Prompt Management The application becomes responsible for implementing an AI subsystem. It has to choose a model provider, manage prompts and context, implement tool calling, handle agent loops, manage authentication and API keys, and probably provide some sort of chat interface. Then the next application implements much of the same machinery again. But why? The user may already have an agentic system capable of reasoning, planning, maintaining context, asking questions, and calling tools. If that is the case, the architecture can be much simpler: Plain Text User | Agentic System | MCP | Application The application does not have to become an AI application. It only has to become agent-accessible. That is a fundamentally different approach. The Agentic Functionality Is Already There This was perhaps the most important realization for me while implementing MCP support in Diptych. If I wanted "AI-powered file management," I could embed an LLM into Diptych. I could put a text field somewhere saying: Plain Text What do you want to do? Then Diptych would have to send that request to a model, provide context, define tools, process tool calls, maintain a conversation, handle failures, choose models, configure credentials, and keep adapting as the AI ecosystem changes. But if the user already has an agentic system, all of this is unnecessary. The agentic functionality is already there. Diptych only needs to provide the application-specific part that the external agent cannot provide itself. It needs to say: Here is what I know. and: Here is what I can do. That is MCP. The agent supplies the intelligence. Diptych supplies its capabilities and application state. MCP connects them. Why Not Just Let the Agent Use the Shell? For a file manager, there is an obvious objection. An agent can already manipulate files through shell commands. Why does it need Diptych? Because Diptych knows something the shell does not: what the user is doing right now. Imagine that I have two directories displayed in Diptych. I have selected four files in the left pane. The right pane shows another directory. Perhaps I also have a directory-comparison window open with specific comparison options. The operating system knows about all these files. But it does not know that these particular directories and files currently form the context of my work. Diptych does. That means I can say to an agent: Compare these two directories, including permissions and extended attributes. Or: Select the files that exist only on the left. Or: Move these files to the directory in the other pane. (F6) The word these suddenly has meaning. The MCP interface gives the agent access not merely to filesystem functionality but to interactive application context. That is easy to overlook when MCP is considered purely as a protocol for backend services. MCP as Another Application Interface We already accept that an application may have several interfaces. A graphical interface exposes capabilities to a human using a mouse and keyboard. A command-line interface exposes capabilities to shell users and scripts. A scripting API exposes capabilities to automation. An MCP interface exposes capabilities and context to an agent. Seen this way, MCP becomes something larger than a mechanism for connecting an LLM to servers. It can become an agent-facing application interface. And once you look at it that way, there is little reason to restrict it to developer tools, databases, or enterprise infrastructure. Personal MCP Imagine ordinary desktop applications exposing interfaces like these: Plain Text Mail search_messages get_selected_thread draft_reply Calendar get_visible_week find_free_time propose_event Image Editor get_current_document get_selection resize_selection export_image IDE get_open_files get_current_selection run_tests show_diff File Manager get_pane get_selection compare_directories propose_file_operations None of these applications needs its own LLM. None needs its own general-purpose agent. And perhaps none needs yet another little "Ask AI" button. They need a vocabulary through which an agent can understand what they know and what they can do. The user can then choose the agentic system. The applications provide capabilities. Applications No Longer Need to Integrate With Each Other This becomes even more interesting when several personal applications expose MCP. Imagine telling an agent: Find the PDF invoices I downloaded last month, move them into the accounting folder, attach them to a draft email to my accountant, and show me the draft before sending anything. Several applications may be involved. The file manager understands the filesystem and the user's current file-management context. The mail application understands messages, recipients, drafts, and attachments. Another application may understand the contents and metadata of the PDFs. Traditionally, these applications would need explicit integrations with one another. Application A needs an API for application B. Someone has to implement that integration. Then perhaps B needs integration with C. With an agent in the middle, the architecture can look very different: Plain Text Agent / | \ MCP MCP MCP / | \ File Manager Mail Calendar Applications don't need to know anything about one another. They simply expose their capabilities. The agent performs the orchestration. That is almost an inversion of traditional application integration. The Security Boundary Still Belongs to the Application This creates an obvious danger. If an agent can operate applications, it can also make mistakes at machine speed. MCP should therefore not mean: Plain Text Agent says something ↓ Application obeys Diptych deliberately does not work that way for potentially destructive filesystem operations. An agent can propose a batch of operations. Diptych presents the concrete operations in its own native UI. The user can inspect them, modify individual operations, deselect some, approve them, or cancel the whole batch. In addition, Diptych keeps track of a long undo list, and most operations can be reversed. The agent may interpret: Move the old log files to the archive and make them read-only. But before execution, the application can show concrete operations such as: Plain Text MOVE server-2025.log → archive/server-2025.log CHMOD archive/server-2025.log → r--r--r-- MOVE audit-2025.log → archive/audit-2025.log CHMOD archive/audit-2025.log → r--r--r-- The user approves operations, not merely the agent's interpretation of the request. That separation matters. The agent is responsible for reasoning. The application remains responsible for defining what operations are possible, validating them, enforcing authorization, and ultimately controlling execution. MCP does not remove the application's security boundary. If anything, agent access makes that boundary more important. The GUI Is Not Going Away None of this means that agents should replace graphical interfaces. Looking at files visually is useful. Comparing directories visually is useful. Editing an image visually is essential. Viewing a week as a calendar can be much more useful than asking an agent about every appointment. What changes is that the GUI is no longer necessarily the only interface through which intent enters an application. Today, we mostly have: Plain Text Human → GUI → Application Increasingly, we may also have: Plain Text Human → Agent → MCP → Application And frequently both at the same time. I can manipulate files directly, ask the agent to perform something tedious, inspect its proposed operations in Diptych, change something manually, approve the rest, and continue working. The agent does not replace the GUI. It becomes another way of controlling it. Applications as Capability Providers This suggests a design principle that I think may become increasingly important: Do not necessarily put an agent into your application. Make your application usable by agents. The distinction sounds subtle, but architecturally it is substantial. Embedding an agent couples an application to a rapidly changing AI stack. Providing MCP describes what the application knows and what it can safely do. That is much closer to the application's real responsibility. A file manager should be exceptionally good at manipulating files. A calendar should be exceptionally good at managing calendars. An image editor should be exceptionally good at editing images. The agent should be good at understanding what the user wants and coordinating those capabilities. Where This Is Going Diptych is, as far as I can determine, among the first native macOS personal productivity applications to expose its live GUI state and application capabilities through MCP. That distinction matters. File-oriented MCP servers and even multi-user file-management applications already expose MCP. But that is still close to the way we usually think about MCP: a service provides capabilities and an agent connects to that service. Diptych represents a somewhat different model. It is an ordinary, single-user desktop application. You launch it, see two file panes, select files, open windows, compare directories, and perform operations just as you would with any other macOS productivity application. MCP is simply another interface to that same running application. The agent is not built into Diptych, and Diptych is not primarily an MCP service. It is your application, exposing itself to your agent. I think we will see much more of this. Today MCP discussions are dominated by servers connecting agents to developer tools, databases, web services, and enterprise systems. Tomorrow, MCP may increasingly become an interface to the applications already running on our personal computers. Your file manager exposes MCP. Your mail client exposes MCP. Your calendar exposes MCP. Your IDE exposes MCP. Your image editor exposes MCP. These already exist. None of them needs its own LLM or an agentic system. They expose their state and capabilities, while the agent you already use supplies the reasoning and orchestration. Eventually, you might tell your agent: Put these files into the project archive, attach the report to the email we were discussing, and move tomorrow's meeting by half an hour. The agent may accomplish that by talking through MCP to three independent desktop applications. Those applications do not need integrations with one another. They don't even need to know the others exist. They simply expose capabilities. The agent provides the intelligence. MCP provides the common interface. Diptych is a small example of this model today. I expect it to become a standard feature of desktop applications in the future. Perhaps, a few years from now, asking whether an application supports MCP will be as natural as asking today whether it has an API, plugins, or a command-line interface.
Virtual threads let us keep ordinary blocking code while many more requests wait concurrently. Under load, however, that brings three critical questions into focus: How much work do we admit? Cheap threads can still build an expensive queue.What belongs inside a lock? A sleeping lock holder still makes everyone else wait.Who stops abandoned work? A caller's timeout does not cancel its task. The examples below isolate these questions with separate before-and-after programs. Each program is complete: save its code block under the filename shown and run the accompanying command, without arguments. All you need is JDK 25. They use its compact source-file syntax, so no class declaration, build tool, external library, or preview flag is required. First: More Overlapping Waits Imagine 1,000 tasks that each wait for 100 ms, with no dependency limiting their concurrency: Execution modelSimultaneous waitsIdeal elapsed timePool of 50 platform threads502,000 msOne virtual thread per task, all waits overlapping1,000100 msVirtual threads limited to 50 concurrent waits502,000 ms These are arithmetic estimates, ignoring startup and scheduling overhead. With 50 workers, the waits take twenty batches. Allow all 1,000 waits to overlap, and they take one batch. Cap virtual-thread concurrency at 50 and the twenty-batch result returns. Virtual threads let more independent waits overlap. They do not execute CPU instructions faster. A virtual thread runs on a platform thread called a carrier. Many blocking JDK operations, including socket I/O and sleep, let it unmount while waiting. Its stack and referenced objects remain on the heap, and the carrier can run something else. (The M:N scheduling model: Many virtual threads map to a smaller pool of OS carrier threads) The scheduler's default target parallelism is the number of processors available to the JVM. Some blocking operations still occupy a carrier; native and foreign-function calls can pin virtual threads even on JDK 25. Waiting uses fewer OS threads, but it still costs memory and resources. 1. Bound Admission: Eight Slots Cannot Serve Everybody at Once Imagine a dependency with eight connections. Each call takes 80 ms. Its ideal capacity is 8 / 0.08 = 100 calls per second. Virtual threads can wait cheaply for a connection, but they cannot create another one. Send a burst of 100 requests. First, accept everything. Then, repeat with an admission limit of 32: up to eight using the dependency and another 24 waiting for it. First, accept every request. Save as UnboundedAdmission.java: Java import java.time.Duration; import java.time.Instant; import java.util.ArrayList; import java.util.concurrent.*; void main() throws Exception { var connections = new Semaphore(8); var results = new ArrayList<Future<?>>(); Instant now = Instant.now(); try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { for (int i = 0; i < 100; i++) { results.add(executor.submit(() -> { connections.acquire(); try { Thread.sleep(Duration.ofMillis(80)); // the dependency call } finally { connections.release(); } return null; })); } } // wait for all work to finish for (var result : results) result.get(); IO.println("completed=%d rejected=0 elapsed_ms=%d".formatted( results.size(), Duration.between(now, Instant.now()).toMillis())); } Run it: Java java UnboundedAdmission.java Every request waits until a connection becomes available. Now add an admission semaphore so excess requests are rejected immediately. Save as BoundedAdmission.java: Java import java.time.Duration; import java.time.Instant; import java.util.ArrayList; import java.util.concurrent.*; void main() throws Exception { var connections = new Semaphore(8); var admission = new Semaphore(32); var results = new ArrayList<Future<Boolean>>(); Instant now = Instant.now(); try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { for (int i = 0; i < 100; i++) { results.add(executor.submit(() -> { if (!admission.tryAcquire()) return false; try { connections.acquire(); try { Thread.sleep(Duration.ofMillis(80)); // the dependency call } finally { connections.release(); } return true; } finally { admission.release(); } })); } } // wait for accepted work to finish int completed = 0; for (var result : results) if (result.get()) completed++; IO.println("completed=%d rejected=%d elapsed_ms=%d".formatted( completed, 100 - completed, Duration.between(now, Instant.now()).toMillis())); } Run it: Java java BoundedAdmission.java One run on Oracle GraalVM 25.0.2 produced: AdmissionCompletedRejectedElapsed timeUnlimited10001,062 msLimit of 323268330 ms There are two different limits here. connections.acquire() waits for one of eight service slots. admission.tryAcquire() admits the request immediately or returns false, recording a rejection. Its permit stays held while the request waits for a connection and makes the call. Without admission control, all 100 requests complete in thirteen batches, taking roughly a second. With the gate, a quick burst typically admits 32 and rejects 68; four batches take roughly 320 ms. The bounded run finishes sooner because it rejects excess work, preventing memory exhaustion. A 10 KB per-thread cache across 100,000 virtual threads is still 1 GB of heap space. The takeaway: Reject before adding work to the queue you need to bound. 2. Shorten the Lock: Protect the Update Now use forty tasks. Each waits 80 ms and increments a shared completion counter. There is no connection limit here: the waits are independent, so the lock is the only bottleneck. First, put both the wait and the counter update inside the lock. Save as WideLock.java: Java import java.time.Duration; import java.time.Instant; import java.util.ArrayList; import java.util.concurrent.*; final Object lock = new Object(); int completed; void main() throws Exception { Instant now = Instant.now(); try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { var tasks = new ArrayList<Future<?>>(); for (int i = 0; i < 40; i++) { tasks.add(executor.submit(() -> { synchronized (lock) { Thread.sleep(Duration.ofMillis(80)); completed++; } return null; })); } for (var task : tasks) task.get(); } if (completed != 40) throw new AssertionError("Lost an update"); IO.println("completed=%d elapsed_ms=%d".formatted( completed, Duration.between(now, Instant.now()).toMillis())); } Run it: Java java WideLock.java Now move the wait outside the lock, keeping the counter update protected. Save as NarrowLock.java: Java import java.time.Duration; import java.time.Instant; import java.util.ArrayList; import java.util.concurrent.*; final Object lock = new Object(); int completed; void main() throws Exception { Instant now = Instant.now(); try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { var tasks = new ArrayList<Future<?>>(); for (int i = 0; i < 40; i++) { tasks.add(executor.submit(() -> { Thread.sleep(Duration.ofMillis(80)); synchronized (lock) { completed++; } return null; })); } for (var task : tasks) task.get(); } if (completed != 40) throw new AssertionError("Lost an update"); IO.println("completed=%d elapsed_ms=%d".formatted( completed, Duration.between(now, Instant.now()).toMillis())); } Run it: Java java NarrowLock.java On the same JDK, the two runs produced: Lock scopeCompletedElapsed timeWait and update inside403,255 msOnly the update inside4087 ms With the wait inside the lock, forty waits of 80 ms take at least 3,200 ms. Making the other thirty-nine threads cheap to park does not let them pass the lock holder. With the wait outside, those waits overlap. On JDK 25, synchronized is perfectly suitable for this demonstration, as JDK 24 removed monitor-related virtual-thread pinning. The issue here isn't platform pinning; it's basic thread serialization. The rule is to identify the state transition that needs protection, then keep independent slow work outside of it. 3. Cancel the Work: A Timeout Only Ends the Wait Use one task that takes 1,800 ms. Its caller is willing to wait only 200 ms. First, let the caller time out without canceling the task. Save as TimeoutOnly.java: Java import java.time.Duration; import java.time.Instant; import java.util.concurrent.*; void main() throws Exception { Instant now = Instant.now(); try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { var task = executor.submit(() -> { try { Thread.sleep(Duration.ofMillis(1_800)); IO.println("Work completed"); return "done"; } finally { IO.println("Cleanup ran"); } }); try { task.get(200, TimeUnit.MILLISECONDS); } catch (TimeoutException e) { IO.println("Caller timed out"); } } // close() still waits for the task to exit IO.println("scope_exited_ms=%d".formatted( Duration.between(now, Instant.now()).toMillis())); } Run it: Java java TimeoutOnly.java Now add task.cancel(true) to the timeout handler. Save as TimeoutWithCancellation.java: Java import java.time.Duration; import java.time.Instant; import java.util.concurrent.*; void main() throws Exception { Instant now = Instant.now(); try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { var task = executor.submit(() -> { try { Thread.sleep(Duration.ofMillis(1_800)); IO.println("Work completed"); return "done"; } finally { IO.println("Cleanup ran"); } }); try { task.get(200, TimeUnit.MILLISECONDS); } catch (TimeoutException e) { IO.println("Caller timed out"); task.cancel(true); // <--- Explicit cancellation } } // close() waits for the task to exit, even after cancellation IO.println("scope_exited_ms=%d".formatted( Duration.between(now, Instant.now()).toMillis())); } Run it: Java java TimeoutWithCancellation.java The observed outcomes were: ExamplePrinted Work completed?Printed Cleanup ran?Scope exited afterTimeout aloneYesYes1,805 msTimeout plus cancellationNoYes207 ms Without cancellation, get() throws after 200 ms, but the task keeps sleeping. It eventually completes its work 1.6 seconds later. If this abandoned work was holding a database connection or an admission permit, that resource would be leaked for the duration of the sleep. Cancellation is cooperative. Detect the timeout, request cancellation, and wait for the cleanup. All three parts matter. A Teaser: The Structured Concurrency Solution In the final example above, you'll notice the parent thread is forced to micromanage the task's lifecycle. It waits, catches the TimeoutException, explicitly calls cancel(true), and crosses its fingers that the cleanup runs. But what if we could invert this control? With JDK's preview StructuredTaskScope API, we can tie the lifetime of the threads to the lexical scope itself, making cancellation an automatic architectural guarantee rather than manual boilerplate: I will be exploring how StructuredTaskScope eliminates resource leaks and abandoned work entirely in my next article. Stay tuned! Summary Take these three questions back to your services when migrating to virtual threads: Where does waiting accumulate? Bound admission before that queue grows.What serializes the work? Keep independent waits outside state-protecting locks.Who owns unfinished work? Connect deadlines to cooperative cancellation and cleanup. The programs here use sleeps so each effect is visible, but the exact same questions apply when your wait is for a database, a socket, or a partner API.
There's a particular kind of silence that happens on a call when a regulator asks, "Can you walk me through exactly how this number was calculated?" and the person on the other end has to say, "Let me get back to you." I've been on that call. It is not a fun place to be, and it usually isn't because anyone did anything wrong. It's because the pipeline that produced the number was never built to answer that question in the first place. Regulatory reporting, whether it's a liquidity return, a capital calculation, or something more niche, has a strange property compared to most data engineering work. Nobody outside the team really cares how the dashboard behind it looks. What they care about is whether you can prove, months or years later, exactly where every figure came from, who touched it, and why it changed between two reporting periods. That's a fundamentally different design goal than "make the report run fast," and honestly, a lot of pipelines get built for speed and convenience first, with auditability bolted on afterward as an afterthought. It shows. What "Audit-Ready" Actually Means in Practice I think people sometimes hear "audit-ready" and picture some heavyweight compliance layer sitting on top of a normal pipeline. In my experience, it's less about adding a layer and more about changing a handful of default behaviors early, before the pipeline exists in production. Four things tend to matter most. First, nothing gets overwritten. Ever. If a source record changes, you keep the old version and the new one, both timestamped. Second, every transformation step has to be reproducible from code, not from a person's memory of what they clicked in a tool three years ago. Third, there's a reconciliation point, a moment where the pipeline checks its own output against the source and either passes or stops. And fourth, a human actually looks at the number before it leaves the building. Not a dashboard glance, an actual documented sign-off with a name and a timestamp attached to it. Here's roughly how that fits together end to end. A couple of things in that picture are doing more work than they look like they are. The raw zone sitting right after ingestion, untouched and append-only, is what lets you go back eighteen months later and reconstruct exactly what the source system handed you on a given day, independent of whatever the curated tables look like now. I've genuinely had to do this, pulling raw records from a specific date to settle a dispute about whether a discrepancy came from us or from upstream. Without that raw layer, that conversation becomes a lot more uncomfortable. The Reconciliation Step Is Where Trust Actually Gets Built If I had to pick one part of this architecture that matters more than the rest, it's the reconciliation checkpoint. This is the moment where the pipeline stops behaving like a normal ETL job and starts behaving like a control. The logic itself isn't exotic. You compare row counts, sums, and control totals against the source, and you either match within tolerance or you don't. What matters is what happens on the "no" branch. A lot of pipelines I've inherited over the years treated a reconciliation break as a warning buried in a log file that nobody reads until someone complains. That's backward. A break should halt the pipeline and page someone, because a regulatory submission built on unreconciled data is worse than a late submission. I'd rather explain a delay than explain a restated figure. I'll admit there's a tension here worth naming honestly. Hard stops on every mismatch can grind a team down if your tolerance thresholds are too tight, and you end up with alert fatigue where people start ignoring pages because most of them turn out to be rounding noise from a currency conversion. Getting the thresholds right, informed by prior period baselines rather than guessed at, takes real tuning. It's not a solved problem you configure once and forget. Lineage and the Four-Eyes Check The governance band running underneath the pipeline, capturing lineage and version history, is what turns "I think this is right" into "here's the exact model, the exact commit, and the exact person who approved it." On Unity Catalog or an equivalent lineage tool, this is largely automatic once you've built your transformations in something like dbt rather than scattered SQL scripts. That's honestly one of the more underrated benefits of moving regulatory pipelines onto a tool with built-in lineage tracking. You stop having to build that tracking yourself. The human sign-off matters just as much, maybe more, even though it's the least technically interesting part of the whole design. A second person reviewing the output before submission, with their review logged, catches the kind of error that automated tests won't, like a number that's technically correct by every rule you wrote but obviously wrong to a person who understands the business context. A Small Piece of Advice If you're building one of these pipelines from scratch, resist the urge to treat the audit trail as something you'll add once the pipeline is "working." Retrofitting immutability and lineage onto tables that have already been overwritten a hundred times is painful, and in some cases you simply can't recover the history you needed. Build the raw, append-only layer first, even if it feels like overhead in week one. Six months later, when someone asks you to explain a number from last quarter, you'll be glad it's there.
Here's a scenario that plays out more often than anyone wants to admit. A team ships an internal agent to production. Neat little thing. It reads tickets, calls a couple of internal APIs, sends a Slack notification, and marks the ticket resolved. The engineer who deployed it, in the interest of getting something out the door, gave the agent a copy of their own API token. The token has the permissions of a senior engineer, which is to say it can do almost anything. Three months in, someone crafts a support ticket with embedded instructions. The agent, being an agent, treats those instructions as guidance. The Slack notification it sends goes to the wrong channel. The API call it makes touches production data it shouldn't have touched. And the audit log, when someone finally goes to reconstruct what happened, says the actions were performed by the senior engineer who lent their token six months earlier. That engineer is going to have a really bad afternoon. This is not a hypothetical. It's a pattern. And it's happening across the industry right now because most teams shipped agent-shaped software before they thought carefully about identity, and the tooling for doing this right isn't as widely known as it should be. What follows are five identity patterns that any agent in production needs to have in place. None of them are exotic. All of them are borrowed from twenty years of hard-won distributed systems experience, applied to the specific shape of the agent problem. If any one of them is missing, you have an incident waiting to happen. Pattern 1: Give the Agent Its Own Identity The single most common failure I see, and the one that causes the most cleanup work when things go wrong, is the borrowed identity problem. Agent runs under an engineer's credentials. Agent runs under a shared service account nobody owns. Agent runs under whatever role the deploying pod happened to inherit. All of these are the same failure dressed up differently, and the failure is that when the agent does something, you can't tell what the agent did versus what a human did. The fix is unglamorous. Every agent in your production environment gets a distinct machine identity. In cloud terms, that's a service account or a workload identity that belongs specifically to that agent. In identity-provider terms, it's a separate principal that gets audited, rotated, and permissioned in isolation. The SPIFFE and SPIRE projects have been doing serious work on machine identity in cloud-native environments for years, and their model of workload attestation (the agent proves it is what it claims to be through the environment it runs in) maps cleanly onto the agent case. Cloud providers offer their own versions: AWS IAM Roles for Service Accounts, Azure Workload Identity, GCP Workload Identity Federation. Pick one. The point is not the vendor. The point is that when an audit log entry appears, you can tell whether it came from Sarah, from the payments-agent-v3, or from something impersonating either of them. Two easy tests to see if you have this pattern right. First, can you list every agent in your environment along with its distinct identity? Second, if you had to revoke one agent's access this afternoon without affecting anything else, could you? If both answers are yes, you're in good shape. If either is no, the borrowed identity problem is somewhere in your stack. Pattern 2: Scope the Authority the Agent Gets Delegated Once your agent has its own identity, the next question is what that identity is allowed to do. The lazy answer, which is where most first deployments land, is "everything the deploying team could do." The correct answer is "the narrowest possible set of actions required for the agent to do its specific job, and no more." This sounds obvious. In practice, it is where most teams collapse, because designing narrow permissions is annoying and shipping is fun. But it's exactly the pattern that turns a small incident into a large one when things go wrong. The relevant technical primitives here come from the OAuth 2.0 family, and they've matured a lot in the last two years specifically because of agent use cases. The OAuth 2.0 Token Exchange spec lets you swap a broadly-scoped token for a narrowly-scoped one before the agent uses it. OAuth 2.0 DPoP (Demonstrating Proof of Possession) binds tokens to specific clients so a stolen token can't be replayed elsewhere. The OAuth 2.1 draft consolidates these into a modern default. For agent workloads specifically, the pattern that works is: the agent gets its identity from your identity provider, exchanges it for a scoped downstream token before each action, and that downstream token can only do the specific thing the agent needs to do right now. The tokens are typed. The scopes are named. And the blast radius of any single compromised token is one action, not the entire agent's job. The uncomfortable question this pattern forces you to answer is exactly what actions your agent needs to take. Most teams have never enumerated this precisely. Doing so is uncomfortable and clarifying, in that order. Pattern 3: Never Give the Agent Long-Lived Credentials Long-lived credentials are the single most reliable source of production incidents in cloud environments, and they're worse for agents than they are for humans because agents don't rotate their tokens the way humans rotate their laptops. The pattern is credentials that live for minutes, not months. Get them just before you need them. Discard them immediately after. Never persist them anywhere the agent's memory extends beyond a single request cycle. The tooling for this exists and is production-mature. HashiCorp Vault has been doing dynamic secrets and short-lived tokens for years. Cloud provider IAM offers just-in-time credential issuance through workload identity federation. Kubernetes gives you projected service account tokens with configurable lifetimes measured in minutes. The AWS Signing Version 4 family, when paired with STS-issued session credentials, gives you the same shape. The mental model shift that matters is that credentials become a request-scoped resource, not an agent-scoped one. When the agent starts a task, it acquires the credentials it needs. When the task ends, those credentials expire whether you remember to revoke them or not. If the agent's environment gets compromised, the attacker gets whatever credentials happened to be resident in memory at that instant. Which is almost nothing. The specific failure mode this prevents is the one that keeps security teams up at night: a stolen credential file, a compromised container image with baked-in secrets, an agent that got prompt-injected into leaking its own environment variables. All of those attacks become dramatically less useful when the credentials the attacker steals expire in three minutes. Pattern 4: Authorize Per-Tool, Not Per-Agent The moment agents started calling tools, and especially since MCP became a standard for exposing tools to agents, the granularity of authorization became a live problem. The way most first-generation agent frameworks handle this is: the agent has an API key or an OAuth token, and any tool the agent can invoke uses that same credential. Blanket authorization. If the agent has permission for anything, it has permission for everything. The pattern that works is per-tool authorization boundaries. Each tool the agent can invoke has its own scoped credential, tied to that specific tool and no others. When the agent invokes tool A, the credential presented has authority for tool A only. When it invokes tool B, a different credential presents authority for tool B only. Compromise of one tool's credential does not cascade to the others. This maps cleanly onto MCP's tool schema model, where each tool is a named function with a specific parameter shape. The agent's authorization to invoke each function should be independently governed. In OAuth terms, each MCP tool becomes a resource with its own scope. In enterprise-security terms, each tool becomes an object in your authorization policy, and the agent is a subject that has been granted specific access to specific objects. The additional discipline this pattern enforces is that when you introduce a new tool to an agent, you have to make an explicit decision about whether the agent should be authorized to invoke it. That decision goes through your normal access-control review. It doesn't just inherit from whatever the agent already had access to. Which is annoying, and correct. The tools with write access, network egress, or access to sensitive data deserve especially close review. Those are the tools where a prompt-injected agent does the most damage. Treating each of them as a distinct authorization decision is the difference between a controlled system and a system that quietly grows lateral movement paths every time someone adds a tool. Pattern 5: Preserve the Provenance Chain Across Every Hop The final pattern is the one most teams don't realize they need until they're in the middle of an incident. Agents rarely act alone. Agent A calls Agent B, which invokes a tool that talks to a downstream service, which triggers a job that eventually writes to a database. When something goes wrong at the far end of that chain, the question everyone needs to answer is: who initiated this? Not the immediate caller. The original caller. The human, or the scheduled trigger, or the external event that started the whole cascade. Without an intentional provenance chain, that question is often unanswerable. Each hop drops the identity of the previous hop and substitutes its own. By the time you're at the database write, all you can see is that Agent D did it, and Agent D has no memory of why. The pattern is to propagate identity, not replace it. Every hop signs its action with its own identity and includes the identity of the caller that triggered it. The provenance chain accumulates, so at the end of the chain you can read the complete history: user submitted request, request routed to agent A, agent A invoked agent B, agent B called tool X, tool X wrote to database Y. Every step signed. Every step attributable. The specifications and tools for this vary by maturity. SPIFFE-based trust domains support this natively. OAuth token exchange with the act claim preserves acting-on-behalf-of relationships across service calls. In-house implementations often just require a well-designed context header propagated through every service call, containing signed identity claims from each hop. Whatever the mechanism, the invariant that matters is: any action anywhere in your system can be traced back to the human, agent, or event that ultimately initiated it. When a bad outcome shows up in the logs, you can reconstruct who is accountable in real time rather than in a two-week postmortem. That capability is the difference between a system you can operate and one you're afraid to touch. The Objections Worth Answering "This is overkill for our internal tools." Every mature security incident in cloud history started with "this is just an internal tool." Internal agents get compromised. Internal tools become external targets when adjacent systems get breached. The five patterns cost a small amount of engineering upfront and prevent a very expensive class of incident later. "Our IAM tooling doesn't support this natively." Most of it does, actually. Every major cloud provider offers workload identity federation, short-lived tokens, and per-resource scoping. HashiCorp Vault covers most gaps. What's usually missing is not the tooling. It's the discipline to use the tooling. "We'll add this after we have product-market fit." The teams that say this most often are the teams that end up doing an emergency retrofit six months into rapid growth, when adding identity discipline to systems already in production requires touching every deployment. Doing it now, when the system is small, is much cheaper than doing it later, when the system is big and full of assumptions that would need to change. "Compliance hasn't asked for this." They will. When AI-related incidents start appearing in enforcement actions, and they are already appearing, compliance teams get asked what identity boundaries exist around agent workloads. The teams with these patterns in place answer calmly. The teams without them scramble. What This Buys You Getting these five patterns right is not glamorous. It won't win you a conference talk. It won't get you featured on Product Hunt. What it will do is make the difference between an agent that misbehaves and gets caught immediately with a clear blast radius, versus an agent that misbehaves and blows up something you can't easily reconstruct or contain. The teams that internalize this early get to operate agent-shaped software with the same confidence they operate any other production system. The teams that skip it get to write postmortems that are much harder than they needed to be. None of these patterns are novel. They are the accumulated wisdom of two decades of distributed systems security, applied to a new class of workload. The tooling exists. The specifications are mature. What has been missing is teams treating agent workloads with the same identity discipline they'd apply to any other service in their environment. Start with pattern one. The other four follow naturally once the foundation is in place.
Bounded generation: Let AI choose within a space where a deterministic engine guarantees validity. Most attempts to put AI into design tooling adopt a generate-then-fix flow: the model produces an artifact, and downstream steps try to repair it into something valid. For anything that must be manufactured or coordinated, that is backward. This article describes an alternative reference architecture, bounded generation, where the space of possible outputs is constrained up front so that everything the system can produce is valid by construction. The rest of this piece focuses on the solution: its components, the interfaces between them, and the decisions that make the guarantee hold. The Problem With Generate-Then-Fix When a generative model emits geometry directly, correctness is a post-hoc hope. You validate, you repair, you re-prompt, and you still cannot guarantee the next output is buildable, reproducible, or reviewable. The failure surface is unbounded because the output space is unbounded. The safeguards you bolt on afterward — mesh repair, constraint checking, human rework — are all trying to claw back a property the system never had. The real issue is architectural: validity is treated as something you evaluate for at the end rather than something the system cannot violate. The Architectural Principle: Bounded Generation Invert the flow. Define a bounded, valid space first, then let the AI operate only inside it. An expert authors a parametric model whose every parameter combination yields a valid object; the AI selects parameter values; a deterministic engine renders geometry. Correctness moves from a runtime check to a structural property of the system. It is the same instinct behind type systems: rather than validate arbitrary input after the fact, you make invalid states unrepresentable. The parametric model is the grammar, and the parameter bounds are its production rules. Figure 1. Bounded generation: authority (parametric model), contract (parameters), evaluator (headless run plus metrics), and an advisory AI layer that never emits geometry. Illustrative. The Four Components of the Solution The architecture has exactly four parts. Only one is allowed to be non-deterministic, and it is deliberately the part with the least authority. Authority (the model). A deterministic parametric model, in our case an Autodesk Dynamo graph, that is the only source of geometry. Nothing else may produce a vertex, and it is the only component that touches a geometry kernel.Contract (the parameters). The model's exposed inputs, extracted into a small, typed, bounded schema. This is the only surface the AI and orchestration code read and write.Evaluator (the truth). Headless execution for a given parameter vector, followed by a metrics harness that turns the exported geometry into objective numbers.Advisory layer (the AI). The only non-deterministic component. It proposes parameter vectors and interprets metrics, with no path to raw geometry. At worst it picks a suboptimal point inside an already-valid space. The Parameter Schema as a Contract The schema is a load-bearing interface, and three properties make it work. Semantic naming: a parameter is wall_thickness_mm, not slider_7, so the AI and any reviewer know what they are changing. Authored bounds: because the range comes from a human who understands the design, an out-of-range value is not rejected later; it is unrepresentable. Declared couplings: when two parameters must move together to stay manufacturable, the contract records it so an edit to one propagates instead of silently breaking the part. Extracting this is mechanical: a Dynamo .dyn file is JSON, and the exposed sliders already carry their bounds. YAML parameters: - name: wall_thickness_mm # semantic name, not an opaque id type: float min: 1.5 max: 4.0 couples_with: [rib_height_mm] # constraint stitching - name: base_radius_mm type: float min: 20.0 max: 60.0 The Evaluator: Turning Geometry Into Evidence Running the model headlessly is only half of it; the other half measures what came out. A stdlib metrics harness parses each exported mesh (STL or OBJ) and computes objective properties, so a candidate is judged on measurements rather than on how it looks in a render: Export success. Was a mesh actually written? A run can report success and still produce nothing.Watertight and manifold. Welding coincident vertices and counting triangles per edge: a solid is watertight when every edge is shared by exactly two triangles, and any boundary or non-manifold edge is a concrete defect.Geometry metrics. Enclosed volume, surface area, and bounding box, so variants compare quantitatively against targets.Reproducibility fingerprint. A hash of the welded triangles confirms the same vector reproduces the same mesh to tolerance. Because these checks are deterministic, the valid fraction of a whole parameter sweep can be reported as a number rather than asserted. Anatomy of a Request A single design request flows through five stages, and the discipline is in stage three: Resolve intent to a parameter vector within the contract.Execute the engine headlessly with that vector.Reconcile: a zero-exit code only means the file loaded; missing package nodes can silently return null, so check the actual file on disk and the evaluation log before trusting the run.Measure the artifact and return metrics to the advisory layer.The advisory layer iterates or ranks; a human approves. Treating exit code zero as proof of success is exactly how a pipeline ends up quietly shipping empty results. Inside this boundary, the AI maps intent to parameters, runs guided search, learns a surrogate, and ranks candidates. Outside it is a single thing, producing geometry, which stays with the deterministic engine permanently. Keeping the line there lets you adopt more capable models without adding risk. What You Gain and the Trade-Offs Validity by construction. The buildable-space guarantee removes the largest class of failures.Reproducibility and auditability. A design is a vector, verifiable by fingerprint and reviewable as a diff of named parameters, not an opaque mesh.Confidentiality. The advisory layer touches only abstract parameters and metrics, so proprietary internals stay in-house.The cost. Someone must author the model and its bounds, parameters must be named meaningfully, and large spaces need surrogate models rather than exhaustive sweeps. If you need open-ended novel form, this pattern constrains you on purpose. Status and Summary The deterministic substrate, parameter extraction, headless execution, sweeps, and the metrics evaluator are implemented and running on generic models; the AI advisory layer is architecture and roadmap, not benchmarked results. The value of the pattern is that it makes those AI roles safe to add on a foundation that already guarantees validity. Define the valid space first, extract it as a typed contract, measure every result, and confine the AI to choosing inside that space. Constrain the space first, and the AI becomes a trustworthy collaborator inside guardrails instead of an unpredictable geometry generator.
Agile
Career Development
Methodologies
Team Management
The Jobs Matrix for AI: Four Boxes Instead of 40 Tools
October 12, 2026
by Stefan Wolpers
CORE
Why Your GPU Fleet Is Both Full and Idle: Building a Capacity Orchestration Layer
October 9, 2026 by Ankit Sinha
Kill the Worker, Keep the Research: Build a Recoverable LangGraph Agent on Temporal
October 5, 2026
by Akhil Madineni
CORE
AI/ML
Big Data
Databases
IoT
Data Integration Landscape 2026: Event Streaming, API, and Batch in the Era of Agentic AI
October 20, 2026
by Kai Wähner
CORE
Two Engines, One Query: DuckDB Inside Neo4j
October 20, 2026
by Akmal Chaudhri
CORE
OpenCode: Setting Up an Open-Source AI Coding Assistant
October 20, 2026
by Gunter Rotsaert
CORE
Cloud Architecture
Integration
Microservices
Performance
Data Integration Landscape 2026: Event Streaming, API, and Batch in the Era of Agentic AI
October 20, 2026
by Kai Wähner
CORE
Running a Self-Hosted WAF in Production: Practical Lessons From SafeLine
October 20, 2026 by Carrie Luo
OwlVigil Open-Sources Its Go SDK for Enterprise AI
October 20, 2026 by Jake Tao
Frameworks
Java
JavaScript
Languages
Tools
Running a Self-Hosted WAF in Production: Practical Lessons From SafeLine
October 20, 2026 by Carrie Luo
Claude Code Superpowers: The 5-Tool Stack That Ends Limits and Context Amnesia
October 20, 2026
by Ram Ghadiyaram
CORE
Two Engines, One Query: DuckDB Inside Neo4j
October 20, 2026
by Akmal Chaudhri
CORE
AI/ML
Java
JavaScript
Open Source
Data Integration Landscape 2026: Event Streaming, API, and Batch in the Era of Agentic AI
October 20, 2026
by Kai Wähner
CORE
Two Engines, One Query: DuckDB Inside Neo4j
October 20, 2026
by Akmal Chaudhri
CORE
PWA on iOS, Part 1: PWAs on the iPhone Are Older Than They Look
October 20, 2026 by Konstantin Shkurko