A terminal coding agent is a loop, a tool registry, a context assembler, a permission layer, and a renderer. Only one of those has anything to do with code. Strip the builtin tools, replace the system prompt, register tools for a different domain, and you have a harness for something else entirely. This is that move carried all the way to a URL: a read-only SQL analytics service for people who cannot write SQL. Built on pi's extension surfaces, with no fork and no from-scratch agent loop.
github.com/tacoda/pi-harness-sql-analyticsANTHROPIC_API_KEY--no-builtin-tools · read-only SQLite · SELECT-only guardanalytics.example.com = wherever you put it
Almost nothing. Take the inventory honestly: the coding is a thin layer of tools and a system prompt. The machinery underneath does not care what domain it is in.
| Part | Coding-specific? | What it actually is |
|---|---|---|
| The agent loop | No | Sample, call tools, feed results back, repeat until done |
| Context assembly | No | System prompt, project files, skills, history, compaction |
| Permission layer | No | Which tools may run, and what has to be confirmed |
| Session and renderer | No | A terminal UI, a transcript, a resumable tree |
| Builtin tools | Yes | read, write, edit, bash, glob, grep |
| System prompt | Yes | "You are a coding agent working in a repository" |
Two rows are domain-specific and the rest is general infrastructure that took real work to build. The interesting question is not "how do I write an agent loop" but "why would I write a second one". If a coding agent exposes its extension surfaces, remove the two coding-specific parts and supply your own. That subtraction is the fastest path to a non-coding harness.
The user is a support lead, an ops person, or a PM. They have a question in plain English,
"how many accounts churned in June?", and no SQL. The harness inspects the schema
and writes one guarded read-only SELECT. It runs the query against a replica,
sanity-checks the result, and answers in a sentence with the number in it. The extension
appends every question and every query that ran to an audit log.
$ ./bin/pi-sql -- "how many accounts churned in June 2026?"
→ list_tables() accounts, subscriptions, plans, events
→ describe_table(events) id, account_id, kind, occurred_at
→ run_query(SELECT COUNT(DISTINCT account_id) FROM events
WHERE kind='churn' AND occurred_at >= '2026-06-01' …)
142 accounts churned in June 2026.
Every organization has a queue of "could an AI just answer this" questions that are not coding questions: analytics, triage, log spelunking, document lookup. The instinct is to build a bespoke agent for each. A coding agent you have already paid for, adopted, and learned to operate is usually the better substrate. The work there is subtraction rather than construction.
One flag deletes the coding half. One file supplies the replacement. These two moves are the entire conversion, and everything after them is refinement.
--no-builtin-tools drops read, write,
edit, and bash at the pi layer. That flag is not cosmetic. It
leaves the agent no general-purpose way to touch the filesystem or shell out. So the only
verbs it has are the ones you register. An agent whose entire vocabulary is four domain tools
cannot wander, because there is nowhere to wander to.
The replacement system prompt lives in .pi/SYSTEM.md and pi loads it from the
project. Note what the launcher therefore does not need to do:
# No --system-prompt here: pi loads the replacement prompt from
# .pi/SYSTEM.md on the mounted repo (strategy A).
That comment records a design decision. The prompt is a file in the project, not a flag in a launcher. So you can review it, diff it, and version it with the tools it describes. A system prompt passed as a command-line argument is a system prompt nobody reviews.
Subtract the domain, then declare the new one. If your substrate has no equivalent of
--no-builtin-tools, treat that absence as a warning. A harness you cannot
narrow is a harness whose blast radius you do not control.
With the builtins gone, the tool registry is the harness's capability surface. Four tools is the whole vocabulary, and each one is narrow on purpose.
ls, so this tool is how it finds out what exists at all.
SELECT, capped in rows and bytes, audited on every call. The one tool with real power, and therefore the one with the guard on it (rung 3).
sql-output/findings.jsonl. The only write in the whole harness, and it goes to a mounted output directory, not anywhere the data lives.
Registration is ordinary, and the plainness is the point. pi.registerTool five
times and pi.registerCommand("ask", …) once, in a single TypeScript file. There
is no agent loop in that file, no retry logic, no context management, no renderer. All of it
comes from pi.
Ask what the agent could do that you have not thought about. With four narrow tools the
answer is short, and you can enumerate it. With bash in the registry the
answer is "anything the container can do", and isolation becomes your only defense.
Narrow tools are cheaper than sandboxes and easier to reason about.
You can air-gap a file-only harness with --network none. This harness cannot:
the model API is on the network. So the containment lives at the data layer, in three
belts, arranged so no single mistake exposes a write.
node:sqlite opened with readOnly: true. SQLite rejects every write at the engine level, so this belt holds even if the other two have a bug.
SELECT or WITH only. Defense in depth: it gives the model a clean reason string instead of a raw SQLite error it cannot interpret.
The guard is worth reading closely, because the interesting part is not the keyword list. It is the scrubbing that happens first.
function scrub(sql: string): string {
// Neutralize comments and string/identifier literals so keywords or
// semicolons *inside* them don't trip the checks below.
return sql
.replace(/--[^\n]*/g, " ")
.replace(/\/\*[\s\S]*?\*\//g, " ")
.replace(/'(?:''|[^'])*'/g, "''")
.replace(/"(?:""|[^"])*"/g, '""');
}
function checkReadOnly(sql: string): { ok: boolean; reason?: string } {
const raw = sql.trim().replace(/;+\s*$/, ""); // allow one trailing ';'
if (!raw) return { ok: false, reason: "empty query" };
const scrubbed = scrub(raw);
if (scrubbed.includes(";"))
return { ok: false, reason: "multiple statements are not allowed" };
const head = scrubbed.replace(/^[\s(]+/, "").toUpperCase();
if (!/^(SELECT|WITH)\b/.test(head))
return { ok: false, reason: "only SELECT / WITH queries are allowed" };
if (FORBIDDEN.test(scrubbed.toUpperCase()))
return { ok: false, reason: "query contains a forbidden keyword" };
return { ok: true };
}
Without scrub, belt 2 rejects a legitimate query that contains the word
"update" inside a string literal. It also reads a semicolon inside a literal as statement
stacking. A guard that produces false refusals on valid work is a guard the operator
eventually removes. The scrubbing is therefore what keeps the belt in place.
The repo duplicates the rules as plain JavaScript in docker/sql-guard.test.mjs.
The source carries a comment saying so, and saying that changing one means changing the
other. That duplication is a deliberate trade: the spec runs in the container. A security
rule with a runnable spec beats a rule with a single definition and no test.
The reflex answer for agent isolation is the network sandbox. It does not apply here, because the harness must reach the model API. So name the belts, and know which one is load-bearing: the read-only connection, not the container. That knowledge is the difference between security and the feeling of it.
Tools give the agent verbs. They do not give it a method. The answer loop is a skill the
model loads when the question arrives. And /ask is the one-line entry point a
non-engineer actually types.
Skills load on demand, so the method costs nothing on turns that do not need it. The description is the trigger, and it names a set of conditions rather than a topic:
name: analytics-playbook
description: Plain-English-to-SQL answer loop (understand the question →
inspect schema with list_tables/describe_table → write one guarded SELECT →
run_query capped → sanity-check → answer in one sentence and save_finding).
Load when the user asks a data/business question, asks for a
count/total/breakdown, or invokes `/ask`.
Two details in that description are worth copying. It spells the loop out as an arrow chain, so the model has the method before the body. And the load condition names three concrete triggers instead of "load when relevant". That vaguer instruction produces skills that never load or always do.
/ask. The user-facing surface. It exists so a non-engineer has one thing to type and does not have to phrase a good agent prompt.
The playbook's loop includes sanity-check before answer. A query that runs is not a query that answers the question asked. The failure mode of this harness is not a crash: it is a confident sentence containing the wrong number. That step is cheap, and it is what stands between a fluent answer and a wrong one.
A harness a colleague cannot start is a demo. The launcher collapses build, seed, and run into one command. The container is the outer belt, outside everything the extension does.
docker_args=(
run --rm -i
--read-only # rootfs is immutable
--tmpfs /tmp:rw,size=64m # the only scratch space
--cap-drop ALL
--security-opt no-new-privileges
-v "$here":/work
-v "$db_path":/data/analytics.db:ro # the one host artifact, read-only
-e ANTHROPIC_API_KEY
…runs as a non-root user
)
Read the mount list rather than the flags. Exactly one host artifact reaches the harness: a database file, mounted read-only. Not a home directory, not a source tree, not an SSH agent socket. Most of what makes a container safe is what you decline to mount into it.
Every one of those bullets keeps someone from running the harness unguarded on a laptop, "just this once". Convenience is a security property when the alternative to your safe path is a fast unsafe one.
The user is not an engineer, so a terminal on their machine was never the delivery mechanism. Two adapters over the same harness process, behind TLS and basic auth.
ttyd in front of the same launcher.
shell2http in front of the one-shot mode.
Rung 5 built the one-shot mode for CI. That mode is what makes the REST adapter a configuration file rather than a service rewrite. Nobody planned that reuse: it happens when you write the launcher to work when stdout is not a terminal.
| Piece | pi strategy | File |
|---|---|---|
| Replacement system prompt | A · repo context | .pi/SYSTEM.md, AGENTS.md |
| Kickoff command | B · prompt template | .pi/prompts/ask.md |
| The answer loop | C · skill | .pi/skills/analytics-playbook/SKILL.md |
| SELECT guard and audit log | D · tool gate | .pi/extensions/sql-analytics.ts |
Domain tools and /ask | E · custom tools/commands | .pi/extensions/sql-analytics.ts |
| Single-command launch | F · launch profile | bin/pi-sql |
| Isolation | outside pi | docker/ |
| Service front door | outside pi | deploy/ |
A coding agent with a system prompt, a tool registry, a way to drop the builtins, and a launch profile is already a general harness. The coding is only a costume, so take it off before you write a new one.