An agent's permissions, its budget and its behaviour — checked before it runs, and enforced while it does.
Ingot compiles a small declarative language to Agent IR: a portable artifact that states what an agent may reach, what it will cost at most, and what it produces. It is a compiler and a toolchain, not a framework and not a runtime.
The point is what it refuses:
$ ingot check
error[ING4007]: `web.search` requires the `network` effect, which no policy rule grants
--> main.ing:31:12
|
31 | hits = call web.search(topic)
| ^^^^^^^^^^^^^^^^^^^^^^ needs `network`
|
= note: Ingot is default-deny: an effect with no rule is denied
= help: add `network allow [...]` to the agent's `policy` block
That is not a lint. network allow ["arxiv.org"] is checked against every call
at compile time — and then enforced at run time, by a filtering proxy on a
container network with no other route out. A tool server that ignores the proxy
reaches nothing, rather than reaching everything.
cargo install ingot-cli
# or download a binary: https://github.com/mathissdupont/ingot/releases
ingot init hello && cd hello
ingot check # types, effects, policy, budgets
ingot run --provider replay --input topic="compiler design" # prints a real artifactA new project ships with a recorded fixture, so that last command produces an answer without contacting anything. Point it at a live model when you want one:
export ANTHROPIC_API_KEY=… # or OPENAI_API_KEY, or GEMINI_API_KEY
ingot run --input topic="compiler design"And ingot studio puts the whole thing on one page — every project, what
compiles, what it may reach, and every run it has had.
Ingot has no unbounded loops and no recursion between agents. That is the feature rather than a gap waiting to be filled.
budget { steps <= 4 } is checked when you compile, and it can only be checked
because the flow has a statically bounded shape. Add while and "how many model
calls can this make?" stops being answerable — so the budget stops being a
promise and becomes a hope. The same trade buys the rest: every capability is
declared and matched against a policy because there are few enough routes to the
outside world to name them all.
Things the compiler catches before anything runs:
- a tool called with the wrong argument type, or with one missing
- a capability the policy denies — or never mentions, which is also a denial
- a prompt placeholder that does not resolve, so
${topci}cannot render empty - an output never emitted, or emitted with the wrong content type
- a loop with no static bound, or a flow that cannot fit its
stepsbudget - recursion between agents, which would make budgets uncheckable
- a tool whose declared reach is wider than the policy containing it
- a credential written into source, refused at build before it can leave
A fair question, and the answer is the same for all three: they are general-purpose, and every property above comes from not being.
-
Budgets need a bounded flow. A language with
whileand recursion cannot promise "at most 4 model calls, at most $5". Forking one gives you strictly more power, and the value here is in having less. - Capabilities need every route out to be nameable. Fork a general-purpose language and you inherit its entire standard library, unannotated, where any function may open a socket or a file.
- The artifact is the product, not the binary. Ingot emits target-neutral IR that a Rust interpreter and a Python backend both consume, with a differential suite asserting they agree. A fast native binary is exactly one runtime — the thing Ingot deliberately is not.
- Execution speed is not the axis. An agent run spends its time waiting on model APIs and tool calls. Nobody's bottleneck is the interpreter.
And on "an LLM writes good Python": it does, and Ingot leans on that —
ingot new --provider auto has a model write the source and the compiler
decide whether it is any good. The problem was never that a model cannot write
Python. It is that nothing checks the Python. A model writing Ingot gets refused
when it grants itself a capability the policy denies.
An agent can stop and ask:
framing = consult(
"Which framing should the report take?",
choices: ["technical", "executive", "narrative"]
)
emit report = ask<markdown>("Write about ${topic} as ${framing}.")
The answer is an ordinary string the flow reads. Two things keep that from
costing what it usually costs:
It is an effect. consult needs human allow in the policy, so "can this
artifact run unattended?" is a question you answer by reading it rather than by
starting it and finding out. CI denies human, and an artifact that needs a
person fails at the gate naming the question instead of hanging on a pipe.
It is recorded. A person is a third source of answers, beside the model and the tools — recorded in its own list and replayed the same way, so an agent with a human in it still has an offline test:
ingot run . --record tests/cassettes/framing.json --approvals stdin --events json
ingot run . --provider replay --cassette tests/cassettes/framing.json # asks nobodyChange the question and the replay refuses, exactly as an edited prompt does —
and the message says re-recording means asking somebody again, rather than
implying it is free. --yes approves gates and cannot answer a question:
there is no safe side to guess.
The same channel carries an approval, so ingot studio can run an agent that
needs a person — one gate at a time, with the effect and the reason shown before
anything happens. See RFC-0020.
Because the artifact states its capabilities, its ceiling and its identity in a machine-readable form, something other than a person can decide about it — a scheduler, a queue, a marketplace, a contract:
| The question | Where the answer is |
|---|---|
| What will this agent do? | the flow and outputs in the Agent IR |
| What may it touch? | the policy block — with values, default-deny, enforced |
| What will it cost, at most? |
budget { tokens, cost }, checked at compile time |
| Is this the same agent I agreed to? | the OCI package's reproducible digest |
| Did it do what it said? | the run record — the event stream, byte-identical on replay |
"At most $5" is a number you have before the run, not a bill afterwards. That is not something a faster language gives you.
Ingot deliberately does not reimplement the layers that already exist:
| Concern | Ingot's position |
|---|---|
| Tool protocol | MCP. No new tool protocol. |
| Agent-to-agent messaging | A2A. No new messaging protocol. |
| Distribution | OCI registries. No new registry. |
| General execution | Existing runtimes, through backends. |
| Sandboxing | Ours — the policy block, enforced. See ADR-0006. |
The language remains the product surface. Templates, editor support and model
assistance all create or edit ordinary .ing; the compiler, cassette tests,
policy-derived containers and backends form one product loop around that source.
See RFC-0007.
The one place Ingot builds its own execution machinery is the last row, and the
scope there is narrow on purpose: a policy-enforcing runner, not a general
execution engine. docs/vision.md is the full picture.
Status: pre-1.0 and moving. The compiler front end is complete, a reference
interpreter runs Agent IR against Anthropic, OpenAI and Gemini, a second backend
emits self-contained Python 3, the policy block is enforced by a real
container boundary, and an agent can put a question to a person and read the
answer without giving up a reproducible run. The language, the Agent IR and the artifact format can still
change between releases. The gap register lists every known
limitation; the changelog says what landed when.
language 0.1
package heptapus.examples.research
type search_result {
title: string
url: string
snippet: string
}
tool web.search(query: string) -> search_result[] !network
verifier CitationCheck(draft: markdown, min_sources: int)
/// A realistic research workflow.
agent ResearchAgent(topic: string) -> report<markdown> {
model requires {
tool_calling
structured_output
context >= 128k
}
tools {
mcp web.search
}
budget {
steps <= 60
tokens <= 120000
cost <= 5 usd
}
policy {
network allow ["arxiv.org", "github.com"]
filesystem_write deny
external_write deny
secrets deny export
}
flow {
queries = ask<string[]>("Create diverse research queries for: ${topic}")
sources = parallel map queries as query {
call web.search(query)
}
draft = ask<markdown>("Produce a source-grounded report.", context: sources)
verify CitationCheck(draft, min_sources: 8)
emit report = draft
}
}
Four complete examples live in examples/.
Requires a stable Rust toolchain (MSRV 1.85).
cargo install ingot-cli # the `ingot` binary
cargo install ingot-mcp # `ingot-mcp-fs`, the reference tool server
cargo install ingot-lsp # the language serverThree crates rather than one because they are three permissions to grant a machine: a compiler, a process that reads your filesystem on an agent's behalf, and something an editor starts. Installing the first does not install the others.
To track main instead of a release:
cargo install --git https://github.com/mathissdupont/ingot ingot-cliEach release carries ingot, ingot-mcp-fs and ingot-lsp for Linux, macOS
(Intel and Apple silicon) and Windows. Download the archive for your platform from
Releases, verify it, and put
the binaries on your PATH:
sha256sum -c SHA256SUMS --ignore-missing
tar -xzf ingot-*-x86_64-unknown-linux-gnu.tar.gzPre-1.0: the language, the Agent IR and the artifact format may change between releases.
Requires a stable Rust toolchain (MSRV 1.85).
git clone https://github.com/mathissdupont/ingot
cd ingot
cargo build --release
./target/release/ingot --helpOn Windows, if the repository sits inside a synced folder such as OneDrive, put
the build directory elsewhere — synchronisation and target/ interact badly:
export CARGO_TARGET_DIR=/c/build/ingot| Command | Purpose |
|---|---|
ingot init <name> [--template brief|document-workflow] |
create a tested starter project |
ingot new [--out-dir dir] "workflow words..." |
create a compiler-verified project from a workflow description |
ingot new --provider auto "workflow words..." |
the same, with a model writing the source and the compiler verifying it |
ingot new --project dir --provider auto "what to change" [--apply] |
propose a source diff for an existing project; writes nothing without --apply
|
ingot new --previous old.ing --candidate proposed.ing [--repair-candidate fixed.ing] |
review model-proposed source, run bounded compiler repair and separate policy requests |
ingot check |
parse, type-check, validate policy and budgets |
ingot fmt [--check] |
canonical formatting |
ingot build [--target ir|python] [--out-dir] |
compile to Agent IR or self-contained Python 3 |
ingot package [--report python] [--out-dir] |
write the checked artifact as an OCI package with a lockfile and a reproducible digest |
ingot package --verify |
report every source, agent and field that moved since the package was written |
ingot ir [--agent] |
print the IR to stdout |
ingot run [--input k=v] |
execute the agent |
ingot run --sandbox |
execute it with each tool server inside a boundary |
ingot run --contained |
execute the agent itself inside a boundary |
ingot run --approvals stdin --events json |
let whatever started the run answer its gates and questions, one at a time |
ingot image build [SOURCE] |
build the version-matched local image used by contained runs |
ingot test |
replay recorded cassettes, tool results included |
ingot doctor [--json] |
report source, provider, MCP and container readiness without starting them |
ingot dev [--run] |
watch source, check and build good revisions, optionally run them |
ingot tools [--json] [--propose] |
discover, preflight and propose MCP tool declarations/routes |
ingot sandbox |
show the boundary each tool server would run inside |
ingot studio |
serve the local surface: projects, run history, and what this machine can reach |
ingot explain <CODE> |
explain a diagnostic in full |
Exit codes: 0 success, 1 the program has blocking diagnostics, 2 the
command itself failed. Diagnostics, progress events and status lines go to
stderr; ingot ir and ingot run write only their payload to stdout, so both
are safe to pipe.
main.ing
│
▼
lexer → parser → AST ingot-lexer, ingot-parser, ingot-syntax
│
▼
name resolution → types → effects/policy ingot-semantic, ingot-lang-types
│
▼
lowering ingot-compiler
│
▼
Agent IR (canonical JSON) ingot-ir
│
┌─────┴──────────┐
▼ ▼
interpreter Python backend ingot-runtime, ingot-backend-python
│ │
▼ ▼
execution portability report implemented: M5
│
▼
MCP servers (stdio) ingot-mcp
With --sandbox the MCP servers move inside a policy-derived boundary
(ingot-sandbox). With --contained the interpreter moves in with them, and
ingot-supervisor is the channel it reaches the model and the operator through.
| Crate | Responsibility |
|---|---|
ingot-source |
files, spans, line/column resolution |
ingot-diagnostics |
diagnostic model, stable codes, terminal renderer |
ingot-lexer |
tokens; never fails, always resynchronises |
ingot-syntax |
AST and the canonical printer behind ingot fmt
|
ingot-parser |
recursive descent with error recovery |
ingot-lang-types |
types, effects, policy subjects and decisions |
ingot-semantic |
resolution, type checking, effect and policy analysis |
ingot-ir |
the Agent IR model and its canonical encoding |
ingot-compiler |
the driver and lowering |
ingot-runtime |
the reference interpreter, providers and cassettes |
ingot-backend-python |
self-contained Python 3 emission and portability reports |
ingot-language-service |
editor-neutral diagnostics, formatting, completion, hover and definition over the compiler |
ingot-lsp |
stdio language server adapter for editor diagnostics, formatting and navigation |
ingot-mcp |
the MCP tool host, and the ingot-mcp-fs reference server |
ingot-sandbox |
a policy block turned into a container boundary |
ingot-egress |
the host-filtering proxy a bounded server's traffic leaves through |
ingot-supervisor |
the channel between a contained run and the host serving it |
ingot-studio |
the loopback server and single page behind ingot studio; holds no compiler, so it can show only what the CLI computed |
ingot-cli |
the ingot binary |
Start here:
- Getting started — install, a project, an offline run, a live one, tools, the boundary, and shipping it.
- The language — a tour of every construct, in one example that grows. Each snippet is compiled by the test suite.
- The toolchain — every command in the loop: what it is for, what it refuses, and why.
Each version is normative for what it defines and inherits the one before it.
-
Language 0.3 —
consultand thehumaneffect - Language 0.2 — imports, optionals and unions, pure helpers, a capability's reach, verifier bodies, persistent memory
- Language 0.1 — syntax and static semantics
-
Agent IR 0.3 — the
consultnode and thehumaneffect -
Agent IR 0.2 — portable node source spans,
verifyconditions - Agent IR 0.1 — the original backend contract
- Runtime 0.5 — resumption and persistent memory
-
Runtime 0.4 — what a
verifydoes, and what a failure ends - Runtime 0.3 — streaming
- Runtime 0.2 — the run record
- Runtime 0.1 — what executing an artifact means
- MCP binding 0.2 — how a declared tool is served
-
agent-ir.schema.json— machine-readable schema - Vision — what the project is for, and where it is going
- Architecture — how the phases fit together
- Language service — editor-facing diagnostics, formatting, completion and navigation
- Decision records — why the load-bearing choices were made
- Gap register — every known limitation, with an identifier
| Milestone | Scope | State |
|---|---|---|
| M0 | scope, prior art, non-goals | done |
| M1 | grammar, parser, diagnostics, formatter | done |
| M2 | types, effects, policy, budgets, Agent IR | done |
| M3 | reference interpreter, ingot run, end-to-end execution |
done |
| M4 | cassette record and replay, ingot test, MCP tool host |
done |
| M5 | a second backend and the portability report | done |
| M6 | OCI artifact, lockfile, reproducible digest | done |
| M7 | language server and editor support | done |
| M8 | conformance suite and backend author guide | done |
| M9 | Ingot Containers — the policy block as an enforced boundary | done |
| M10 |
ingot new — authoring with a model, verified by the compiler |
done |
| M11 | integrated .ing product loop: templates, dev, trace, readiness and safe-run UX |
done |
A number is an identity, not a position in a queue; things get referenced by it, so they keep it. Every milestone is now done. RFC-0007 explains why the usable language loop comes before generation and packaging.
M3 and M4 landed together: the interpreter needed cassettes to be testable, and
cassettes needed the interpreter to be worth recording. M9 landed in two stages —
tool servers contained (RFC-0004), then the run
itself (RFC-0005) — with one piece still open,
GAP-023. M5 closed GAP-018: the
same artifact and cassette now run through two independent backends, and every
unsupported construct is named in a portability report before deployment.
M11 and the Language 0.2 reuse slice are complete: first-use templates,
doctor, dev, human traces, contained-run readiness, editor/LSP support,
typed tool onboarding, imports, optional/union types and pure helpers all now
exist. Issue #11 is now
implemented by Agent IR 0.2: human traces can resolve runtime nodes to portable
.ing source ranges when local source is available. M10 followed M11 rather
than preceding it, which was the point: model assistance accelerates a product
loop that already worked, and produces the same ordinary source that loop was
built around. What it deliberately does not do is grow: policy acceptance, tool
routing and credential handling stayed where they were, and the authoring model
is refused at each of them.
Every known limitation has an identifier and an entry in the gap register — what is missing, how it shows up, why it is not done, and what closing it would take. Read it before relying on anything here.
The ones worth knowing before you write an agent:
| GAP-043 |
parallel map overlaps its iterations, but not when the body calls a tool and not while recording. The result is identical either way; only the wall clock differs. |
| GAP-011 | Imports are project-local. There is no package model, so source is shared by copying it. |
| GAP-039 |
No agent outside this repository is known to run. Every .ing program here was written to exercise the compiler. The friction that would stop you is probably not on this list, because nobody has hit it yet. |
Nothing in the register is unenforced — the class for a limitation that
looks like a guarantee and is not. Every entry either says what it did not do,
fails loudly, or cannot be expressed at all. GAP-001 was the last one in that
class, and closing it is why network allow ["arxiv.org"] now bounds a
contained tool server to that host.
Read CONTRIBUTING.md. Changes to the language, the IR or the artifact format go through the RFC process; everything else can start as an issue.
Apache-2.0. See LICENSE.
Ingot is an open-source toolchain, not a brand. The project claims no rights in
the name and is not seeking a trademark; it is used descriptively. ingot on
crates.io is an unrelated packet-parsing library — it publishes no binary, and
this CLI is distributed as ingot-cli and as prebuilt archives, so nothing
collides. The reasoning, and what would reopen the question, is
GAP-019.
