Write an agent once, as a folder of YAML and Markdown. Run it on seven frameworks. Prove it behaved the same.
no code required · runs air-gapped · every claim measured
cargo run -p pact-cli -- check examples/refund-deskOK — examples/refund-desk loaded cleanly (498 settings).
That example is a supervisor, two specialists, MCP tools, a written refund policy, an eval suite and a learning policy — and not one line of code you have to write. The single Python file in it is a six-line helper the policy carries as an attachment; delete it and you still have a working agent.
| Why PACT | The problem, and why translation between frameworks fails |
| The idea | Contract + strategy, and why that split is the whole design |
| Install | The loader and the adapters from wheels, or from source |
| Tutorial | Nothing → a governed agent, in four steps you can run |
| The PACT language | The same agent in one .pact file: settings, sentences, and going to a folder and back |
| Events and reactions | When this happens, do that: reminders, an agent asked first, an agent told after |
| The one rule | A directory is a field; a field may be a directory |
| What you can express | The full feature surface, by tier |
| How it works | Tree → document → run, and where each check lives |
| Portability, measured | The capability lattice and what the claim covers |
| Advantages | What you get that you did not have |
| Disadvantages | Honest costs, and when to use something else |
| Compared with | Eve, LangGraph, CrewAI, A2A, MCP |
| Runtimes | What runs a PACT tree in production today |
| What's new | What the latest round added to the spec and the adapters |
| Status | What is proven, and what is still owed |
Every serious agent today is written as a program, against one framework's abstractions. That is fine until you need to move it.
graph LR
A["Your agent<br/>written for<br/>LangGraph"] -->|"port it?"| B["AutoGen"]
A -->|"port it?"| C["Pydantic AI"]
A -->|"port it?"| D["OpenAI Agents"]
B -.->|"rewrite"| E["6 weeks<br/>and it behaves<br/>differently"]
C -.->|"rewrite"| E
D -.->|"rewrite"| E
style A fill:#1f2937,stroke:#60a5fa,color:#fff
style E fill:#7f1d1d,stroke:#f87171,color:#fff
Programs do not translate. LangGraph's checkpointed state machine, AutoGen's actor mailbox and Pydantic AI's typed run encode genuinely different semantics. A transpiler between them would have to invent behaviour, and inventing behaviour in an agent that spends money is how you get an outage nobody can explain.
Three more things break at the same time:
| Problem | What it looks like in practice |
|---|---|
| The author cannot write code | The person who knows the refund policy is a support lead. Every framework asks them for Python. |
| The rules live in the code | "Ask a human before refunding over £200" is an if statement in a file nobody in compliance can read. |
| Nothing is measured | You swapped the model. Did behaviour change? Nobody can say, so nobody swaps the model. |
PACT does not model an agent as a program. It models it as a contract about behaviour plus a plural space of strategies for satisfying it.
graph TB
subgraph CONTRACT["THE CONTRACT — portable, invariant"]
C1["what it must achieve"]
C2["what needs a person"]
C3["what it may spend"]
C4["how you know it works<br/>(the eval suite)"]
end
subgraph STRATEGY["THE STRATEGY — selected per target"]
S1["instructions"]
S2["which model"]
S3["shape of the loop"]
S4["which framework"]
end
CONTRACT ==>|"stays the same<br/>everywhere"| RUN["A run"]
STRATEGY ==>|"chosen for<br/>this target"| RUN
RUN ==>|"graded against<br/>the contract"| PROOF["Measured fidelity"]
style CONTRACT fill:#0f2942,stroke:#60a5fa,color:#fff
style STRATEGY fill:#1a2e1a,stroke:#4ade80,color:#fff
style PROOF fill:#2d1f00,stroke:#fbbf24,color:#fff
The contract is what travels. The strategy is what gets chosen. And because agent behaviour is probabilistic, fidelity is never declared — it is measured against the author's own eval suite, which becomes the correctness oracle for framework portability, model substitution and self-improvement alike.
The closest correct analogy is a cost-based query planner. SQL declares what; the planner chooses how against statistics and a cost model. PACT declares the agent's what; the resolver plans the how against a model catalogue and an SLO cost model; and evals replace relational algebra as the correctness oracle.
pip install pact-adapters # the adapters and, through `pact-loader`, the `pact` binary
pact check path/to/workspace # the loader, on the PATH of the environmentTwo distributions, released together at one version:
| Distribution | What it is | Wheels |
|---|---|---|
pact-loader |
the pact binary (crates/pact-cli), no Python code |
Linux, manylinux 2.28, x86_64 and aarch64; elsewhere pip builds it from the source distribution, which needs a Rust toolchain |
pact-adapters |
the Python harness, the loader boundary, the Pydantic AI bridge, the commands; depends on pact-loader at its own version |
one wheel for every platform |
The core of pact-adapters is Pydantic AI (pydantic-ai-slim, with the pydantic and
pydantic-graph it brings), pyyaml, httpx and the loader; every other framework is an extra (adapters/python/README.md). The adapters find
the loader by PACT_BIN, then a checkout's own build, then the pact the wheel installed beside
the interpreter (whether or not that folder is on the PATH), then pact on the PATH.
From a checkout, cargo build --release -p pact-cli builds the loader, and
./scripts/build-wheels.sh builds every file that is published, checks it with twine, and
proves the wheels installed in a fresh environment. .github/workflows/wheels.yml runs that
script on x86_64 and on aarch64, and publishes to PyPI on a tag v<version>, through trusted
publishing, once the owner approves the pypi environment.
Four steps. Every command and every output below is real — run them yourself.
Two files. That is a complete, valid agent.
my-agent/
├── workspace.yaml
└── agents/helper/agent.yaml
# workspace.yaml
name: my-first-agent
description: My first PACT agent.
owner: me# agents/helper/agent.yaml
description: Answers questions about our returns policy.
instructions: >
Answer the customer's question about returns. Be brief. Say when you are
not sure rather than guessing.pact check my-agentOK — my-agent loaded cleanly (8 settings).
Add tools/orders.yaml, and one line on the agent saying it may use it.
# tools/orders.yaml
description: Looks up an order.
url: https://orders.example.com
method: get
actions:
find:
description: Finds one order by its number.
takes:
order-number: text
reads-only: yes
refund:
description: Sends the money back.
takes:
order-number: text
amount: money
spends-money: yes# agents/helper/agent.yaml — added
uses:
- ordersCheck it, and the format teaches you something:
error: 'spends-money' says 'yes', and 'same-request-key' is not set — so nothing
says which argument makes two calls "the same call".
--> tools/orders.yaml:15:5
|
15 | spends-money: yes
| ^^^^^^^^^^^^
fix: Add a line next to it: `same-request-key: ...` — which argument makes two
calls "the same call", so it runs once
rule: schema/missing-companion
Declaring that money moves obliges you to say what makes two calls the same
call. Add same-request-key: order-number and the same refund cannot be sent
twice, even across a crash and a resume.
# questions/may-we-refund.yaml
description: Asks a person before money moves.
says: This refund is over the desk's limit. May we send it?
answer:
approved: yes or no
asked-of:
- the returns team
answer-within: 4h
if-nobody-answers: stop-and-say-so# policies/approvals.yaml
ask-a-person:
- when:
- tool: orders/refund
question: may-we-refund
because: money leaves the company and cannot be taken back# agents/helper/agent.yaml — added
policy: approvals
limits:
cost-per-request-under: 0.05 USD
steps-at-most: 6
when-it-runs-out: stop-and-say-soNow ask what a runtime must be prepared to wait for — before running anything:
pact waits my-agentwait: may-we-refund | reason: needs-approval | asked of: ['the returns team']
| within: 4h | if nobody answers: stop-and-say-so
When that agent runs and reaches the refund, it does not refuse and it does not guess. It parks:
sequenceDiagram
participant C as Customer
participant A as Agent
participant P as Returns team
participant $ as Payments
C->>A: "refund my lamp"
A->>A: looks the order up
A-->>P: parks — "may we send £40?"
Note over A,$: nothing has been paid
P->>A: yes
A->>$: issues the refund (once)
A->>C: "refunded, because the item was faulty"
Say no instead, and the run finishes, the payment tool is never called, and the transcript records "refused: the person answering for the returns team said no" — in their own words.
A workspace is a folder of YAML and Markdown. It can also be one file,
ending in .pact. The two are the same workspace: pact check reads either,
every rule is the same rule, and every adapter runs what comes out. Here is
the whole tutorial above, as one file:
# my-agent.pact
workspace "my-first-agent"
description: My first PACT agent.
owner: me
agent helper
description: Answers questions about our returns policy.
uses orders
policy: approvals
spends under 0.05 USD a request
at most 6 steps
when it runs out: stop and say so
when the agent has taken more than 4 steps
remind it: You are nearly out of steps. Answer with what you have.
instructions
Answer the customer's question about returns. Be brief. Say when you are
not sure rather than guessing.
tool orders
description: Looks up an order.
url: https://orders.example.com
method: get
actions:
find:
description: Finds one order by its number.
takes:
order-number: text
reads-only: yes
refund:
description: Sends the money back.
takes:
order-number: text
amount: money
spends-money: yes
same-request-key: order-number
question may-we-refund
description: Asks a person before money moves.
says: This refund is over the desk's limit. May we send it?
answer:
approved: yes or no
asked-of: [the returns team]
answer-within: 4h
if-nobody-answers: stop-and-say-so
policy approvals
before orders/refund: ask may-we-refund, because money leaves the company and cannot be taken back
pact check my-agent.pactOK — my-agent.pact loaded cleanly (62 settings).
A file is a list of blocks. A block starts at the left margin (workspace "Its name", agent helper, tool orders) and the lines indented under it
belong to it. An indented line is one of three things:
| Line | Looks like | Means |
|---|---|---|
| a setting | description: Looks up an order. |
exactly that field. Anything you can write in a YAML file you can write here, nested the same way, so no field is out of reach |
| a sentence | uses orders · at most 6 steps |
a shorter way of writing one or more settings |
| a text block | instructions alone, with text indented under it |
that field, holding the text as written |
The sentences cover the lines people write most:
| In | Sentence | The same as |
|---|---|---|
| agent | takes message: text, photos: list of images |
accepts: |
| agent | gives decision: one of approved, declined, reason: text |
answers-with: |
| agent | uses zendesk, payments |
uses: |
| agent | asks policy-checker: Checks the request against our policy. |
one line of team: |
| agent | at most 8 steps · at most 20 tool calls · at most 50000 tokens |
lines of limits: |
| agent | spends under 0.50 USD a request |
limits.cost-per-request-under: |
| agent | when it runs out: stop and say so |
limits.when-it-runs-out: |
| agent | listens for topic finance: asked first |
one line of listens-for: |
| tool, knowledge | about finance, tax |
topics: |
| policy | before payments/refund, when amount is more than 200 USD: ask is-this-ok, because … |
one ask-a-person: rule |
| anywhere | when the work touches finance, with what to do indented under it |
a reaction, described in the next section |
A line that is none of the three is refused, on that line, with the nearest sentence:
error: This line is not a setting, and not a sentence this kind of block knows.
--> my-agent.pact:7:3
|
7 | use orders
| ^^^^^^^^^^
fix: Did you mean a line starting `uses`?
rule: lang/not-a-line
A workflow is a flow of work that belongs to no single agent. In a .pact
file it is written like a function: what it takes, what it gives, and its
steps in the order they run.
let large-refund = 200 USD # a constant
workflow route # a function
description: Send one message to the right desk.
takes message: text, channel: text
gives reply: text, optional
where = decide # a branch
by:
- { agent: sorter }
if billing
bill = call billing-desk with message: input.message
reply-bill = answer with reply: bill.reply # `bill` is a variable
if tech
fix = call tech-desk with message: input.message
reply-fix = answer with reply: fix.reply
workflow clear-inbox
description: Route every message in an inbox.
takes messages: list of message
gives replies: list of text, optional
every = for each item in input.messages # a loop
identified-by: [id]
limits:
items-at-most: 50
when-it-runs-out: stop-and-say-so
one = call route with message: item.text, channel: item.channel # one function calls another
answer with replies: every.reply
| You want | You write |
|---|---|
| a constant | let refund-limit = 200 USD, used as {use: refund-limit} |
| a variable | plan = call planner …, then plan.findings |
| a function, and a call | workflow <name> with takes and gives; call <name> with … |
| a loop | for each item in <list> · repeat at most 4 times, with until: beneath |
| a branch | decide, with an if <label> for each way it can go; decide with kind: triage.kind hands the deciding agent what an earlier step found |
| things at once | together |
| a template with parameters | agent desk with base: yes and expects:; then based on desk with domain: billing, cap: 0.10 USD |
None of this is new machinery: the checked document already had each of these, and the checker already had rules for them. So a name no step has is caught before anything runs, at the place it was written:
error: 'answer' reads 'steps.nobody.reply', and this workflow has no stage called 'nobody' there.
--> hello.pact:14:22
|
14 | answer with reply: nobody.reply
| ^^^^^^^^^^^^
fix: Use one of the names offered: `steps.greet`, `steps.answer`.
rule: loader/a-binding-to-nothing
Two things are left out on purpose: there is no arithmetic and no reassignment. What a step is given is a path to a value, which is what lets the checker prove what has run before what.
A workspace can be several .pact files in one folder, split by subject.
examples/language/help-centre is four
files: figures, a pattern and two desks made from it, tools and an approval,
the two workflows above, a reaction, an agent that listens, and a learning
block that says the system may propose improvements and a person decides.
Guide: site-docs/guide/programs-in-the-language.md.
pact write my-agent # print any workspace as one .pact file
pact build my-agent.pact --to my-agent # write the folder a file stands forWrote 7 file(s) to my-agent. `pact check my-agent` reads the same workspace as `pact check my-agent.pact`.
Neither form is the "real" one. Use the file while a workspace is small enough
to read in one sitting, and the folder when several people edit it or it
carries documents and scripts. A .pact file can also sit at the top of a
folder, beside the things that are files by nature, and several .pact files
in one folder are read as one workspace.
- One reader for values. A setting in a
.pactfile is handed to the same YAML reader the folder form uses. The language has no second opinion about what a value means. - One set of rules. The file is turned into the document first, and the checker reads the document. There is no checker for the language.
- A printer that checks itself.
pact writewrites each block, reads its own text back, and compares. Where a sentence would not come back as the same fields, it writes a plain setting instead. A field added to the specification next year needs no change to the language. - A test over everything shipped. Every workspace under
examples/is printed as a.pactfile and read back to the identical document.
What does not make the trip: comments. They are not part of the workspace, so
pact write and pact build drop them.
Full reference: site-docs/reference/language.md.
Tutorial: site-docs/guide/first-agent-in-one-file.md.
For somebody changing the reader or printer: crates/pact-lang/README.md.
Limits and approvals say what an agent may do. A reaction says what should happen when something occurs while it works: remind the agent of something, ask another agent first, tell another agent afterwards, or stop.
agent support
uses orders, billing
when the agent has taken more than 6 steps
remind it: Answer with what you have, and say what you could not find out.
when the agent is about to call billing/issue-refund
consult finance-desk
| It waits for | Written | Certain? |
|---|---|---|
| a moment in the run | when the agent is about to call invoices/send · when the agent has taken more than 6 steps · when billing/issue-refund has answered |
exact |
| a topic the work turns to | when the work touches finance |
exact when a tagged tool is used; a guess when the agent says so itself |
| a judgement | when it looks like the customer is upset — a second model answers yes or no |
a guess |
The rule that separates them: a guess may advise and may never stop a
run. A judge can be wrong and an agent can forget to say what it is working
on, so pact check refuses stop on anything guessed
(loader/a-guess-cannot-stop). Stopping is written on something exact.
A topic is what the work can be about, given a name. Tag a tool with it, and the moment that tool is about to be used the work is known to be about that topic, with nothing guessed:
topic finance
description: Money moving — refunds, invoices, credit, tax.
tool billing
about finance
An agent can then subscribe from its own block. No other agent's block changes:
agent finance-desk
description: Knows the rules for refunds, invoices and tax.
listens for topic finance: asked first
agent auditor
description: Keeps a note of every refund.
listens for event refund-issued: told after
asked first means every other agent whose work touches finance waits for the
finance desk's answer. told after means the auditor hears about it and its
answer is kept with the run, not shown to the agent whose work it was.
On examples/kernel/finance-first, a customer
asks for a refund:
sequenceDiagram
participant S as Support agent
participant F as Finance desk
participant P as A person
participant B as Billing tool
participant A as Auditor
S->>B: issue-refund (tagged finance)
Note over S,B: the call is held, nothing is paid
S-->>F: asked first
F-->>S: "a refund needs the order number and the reason"
S->>B: issue-refund, again
S-->>P: may we send it?
P->>S: yes
S->>B: the refund goes out, once
S-->>A: told after
A held call is not put to a person in the same step: the person is asked once,
about a call that will actually happen. That whole path is a test, run from
the folder and from the .pact file.
| A guess cannot stop a run | checked before anything runs |
| A reaction does not wake itself | however agents consult each other, it ends |
| A consulted agent is paid from the same budget | its cost is charged to the limits of the run that woke it |
| Listening is followed like teamwork | what one agent read reaches the agent it wakes, so the rule that looks for a data leak follows consult, tell and listens for |
| State survives a pause | a run parked for a person's answer resumes knowing what has already fired |
The Python harness carries all of this out. The TypeScript port offers the same tools and reports each reaction it did not carry out.
Guide: site-docs/guide/events-and-reactions.md.
Full reference: site-docs/reference/reactions.md.
A directory is a field; a field may be a directory.
ONE FILE A TREE
───────────────────────────── ─────────────────────────────
agents/helper/agent.yaml agents/helper/agent.yaml
description: Answers... ≡ description: Answers...
instructions: Be brief. agents/helper/instructions.md
Be brief.
Both produce the identical document. A beginner writes one file; an expert writes a tree; nobody learns a slot table. Any future field gets its directory form for free — which is what makes the format extensible without touching the core.
Vercel's Eve proved path-as-identity works, but hard-codes an 18-case slot
table, allows escape hatches only in TypeScript, and — decisively — its runtime
never reads the authored tree: it loads generated ESM produced by a build step
that executes author code. PACT's tree is executable as-is, and pact check
never runs a line of yours.
64 kinds, 545 fields. 327 are core — what a non-technical author writes.
218 are expert, and none of them is required for any core capability.
| Capability | You write | What it buys |
|---|---|---|
| Tools | tools/*.yaml, uses: | HTTP, MCP servers, or another agent — one vocabulary |
| Written procedures | skills/*/SKILL.md | Policy in Markdown a compliance team can read and edit |
| Documents | knowledge/*/documents/ | A corpus to look things up in, with citation rules |
| Human approval | policies/, questions/ | The run parks, serialises, and resumes — across a crash |
| Ceilings | limits: | Steps, tool calls, tokens, money, wall-clock — enforced, not suggested |
| Redaction | redaction.yaml | Card numbers never leave, in plain sentences |
| Rules mid-run | interceptors/ | Hide, stop, redirect or rewrite — a closed vocabulary of eight sentences |
| Shape of thinking | loops/ | ReAct, plan-then-do, reflexion, tree-of-thought, and your own |
| Teams | team:, teamwork: | Delegation, quorum, races, budget shares — eight worked patterns ship |
| Memory | remembers: | A variable a run reads into a call and writes back from one |
| Exact logic | programs/ | WebAssembly in a locked room, with fuel and a consent gate |
| Runtime plugins | plugins/*.yaml, plugins: | Code your runtime loads, named with a version range — refused or reported where it cannot be loaded |
| How you know | evals/ | The correctness oracle for portability, model swaps and learning |
| Self-improvement | learning.yaml | Changes arrive as reviewable diffs, class-gated, never silent |
One shape, several desks. expects: declares the holes, with: fills them,
based-on: inherits by shallow merge, base: yes marks a shape nothing runs.
Inheritance, encapsulation and polymorphism — written the way everything else is.
# agents/desk-pattern/agent.yaml — a pattern, not a desk
expects:
domain: { shape: text, help: what this desk answers questions about }
daily-cap: { shape: money, help: the most one request may cost }
description: A desk that answers questions about <domain>.
limits:
cost-per-request-under: <daily-cap>
# agents/refunds/agent.yaml — one of the desks
based-on: desk-pattern
with: { domain: refunds, daily-cap: 0.05 USD }And it erases itself. A tree written that way and one written out longhand are not similar — they are the same document, down to the digest:
pact discover tests/trees/two-desks-one-pattern # sha256:554f1ac71be9a23f…
pact discover tests/trees/two-desks-longhand # sha256:554f1ac71be9a23f…That is why every claim below — portability, digests, conformance — is true of trees that use these features, with no special-casing anywhere under the loader.
graph TB
T["<b>Your tree</b><br/>YAML + Markdown"] -->|"the Expansion Rule"| D["<b>One document</b><br/>plain data"]
D --> CK["<b>pact check</b><br/>~40 checks a schema<br/>cannot make"]
D --> SH["<b>pact show</b><br/>canonical JSON<br/>+ content digest"]
D --> WA["<b>pact waits</b><br/>every gate a runtime<br/>must be ready for"]
D --> DI["<b>pact discover</b><br/>an inventory to index"]
D --> H["<b>The harness</b>"]
H --> R1["Pydantic AI"]
H --> R2["LangGraph"]
H --> R3["LangChain"]
H --> R4["AutoGen"]
H --> R5["OpenAI Agents"]
H --> R6["Anthropic"]
H --> R7["Vercel AI · Node"]
style T fill:#0f2942,stroke:#60a5fa,color:#fff
style D fill:#1a2e1a,stroke:#4ade80,color:#fff
style H fill:#2d1f00,stroke:#fbbf24,color:#fff
Nothing in that path executes your code. pact check reads a tree and
decides nothing about safety by running anything — which is what makes it safe
to check a workspace a stranger sent you.
Every diagnostic carries where, what, why and how. A fix is required by the constructor's signature, so an unactionable error cannot be built:
error: 'measured-at' should be one of: p50, p90, p95, p99, mean, max, but it is some text.
--> agents/refund-desk/limits.yaml:9:14
|
9 | measured-at: usually
| ^^^^^^^
fix: Change it to one of: p50, p90, p95, p99, mean, max.
rule: schema/wrong-type
Not everything is a complaint. A note says something true that is not wrong,
and --deny-warnings stays green for one — a fact printed as a problem teaches
an author to stop reading the output:
note: 'refund-policy' holds 5 written rules under `## Rules` — lines a person
has to approve before they change, whoever or whatever proposes it.
fix: Nothing to do. Move a rule out from under `## Rules`, or reword that
heading, and it stops being one — which is how you move this boundary.
One folder, loaded by the Rust CLI, executes over all seven targets and
produces byte-identical traces, tool sequences and model-call counts. The claim
is bounded and the bound is written down: it covers instructions:, tools:,
skills:, knowledge:, team:, answers-with:, the loop:, the limits:
ceilings and whether a plugins: line stops the agent before it starts — and
not the fifteen governance keys the Node port reports on
unenforced, nor the documents under knowledge/, which nothing on that port
can look anything up in and which it names on unretrieved for every set an
answer did not come from. See
§7.28 What "byte-identical across all seven targets" covers, and what it does
not.
That ten counts the excluded keys, not the lines a run prints: team: is
inside the claim and reported anyway, since the names are offered to the model
and asking one only comes back as an error there — and every limits: key that
port does not read gets its own line under a limits. prefix.
The conformance suite compares 31 agents × 7 targets = 186 pairs, 0 divergences, with every exclusion declared rather than discovered.
Each target takes the framework's lowest seam, so none of them owns the loop:
| Target | Seam taken |
|---|---|
| Pydantic AI | direct.model_request — below Agent |
| LangGraph | func.entrypoint / task — the functional API, not StateGraph |
| LangChain | BaseChatModel — below chains, AgentExecutor and LCEL |
| AutoGen | ChatCompletionClient — below AssistantAgent and group chat |
| OpenAI Agents | models.interface.Model — below Runner, handoffs and guardrails |
| Anthropic | messages content blocks — explicitly not the hosted session loop |
| Vercel AI SDK | a LanguageModelV2 provider with stopWhen: stepCountIs(1) |
The Vercel target runs in Node, so it is a second, independent port of the same specification — cross-runtime agreement, which is a stronger claim than one implementation agreeing with itself.
Each publishes a capability lattice, and it records real differences rather than flattering uniformity:
model_cal | tool_call | text_with | parallel_ | streaming | durable_r | connected
reference native | native | native | native | unsupport | unsupport | unsupport
pydantic-ai native | native | native | native | emulated | unsupport | native
langgraph native | emulated | native | emulated | emulated | native | unsupport
langchain native | native | native | native | emulated | unsupport | unsupport
autogen native | native | emulated | native | emulated | unsupport | unsupport
openai-agents native | native | native | native | emulated | unsupport | unsupport
anthropic native | native | native | native | emulated | unsupport | unsupport
vercel-ai native | native | native | native | emulated | unsupport | unsupport
LangGraph is the only target with native durable resume. AutoGen cannot carry an
assistant sentence and tool calls in one result, so its text rides in
thought — the value survives and the difference is declared instead of hidden.
Pydantic AI is the only target whose connect: reaches a real MCP server.
The kill test passes on all six Python targets. Request approval mid-parallel-tool-batch, serialise the paused state, drop every object, resume in a fresh transport — each tool executes exactly once, the decision is honoured, and the resulting history is identical across frameworks.
The author does not write code. Not "mostly" — every core capability has a
plain-words expression, and a workspace that reaches for an expert feature loses
its no-code badge rather than quietly becoming a programming project. That is
enforced by a test over the specification's own tiers.
The governance is the artefact. "Ask a person before a refund over £200" is a
file in policies/, not an if in a codebase. Compliance can read it, diff it
and sign it. pact waits lists every gate before anything runs.
Silent failure is designed out. A run reports five honesty channels — what it could not enforce, could not measure, could not retrieve, could not reach, and what can never be reached at all. A ceiling nothing can measure says so instead of passing.
It runs air-gapped. No network is required to check, load, plan or run against a local model. Nothing fetches a schema at runtime.
Model portability is measured, not asserted. --choose-model filters the
catalogue on needs:, runs your eval cases against each candidate, refuses when
none passes, and names the cheapest that does.
Self-improvement emits source. Every change is a reviewable, revertible diff
to a spec file, classified before it applies. A written rule under a ## Rules
heading needs a person however the edit is made — added, removed, reworded, or
moved by renaming the heading over it.
Scale is cheap. A workspace of 525 agent files with a four-level inheritance chain — 4,923 settings, 521 runnable agents once the patterns resolve away — checks in 0.26 seconds, and every one of them runs.
Written plainly, because a specification that only lists its strengths is marketing.
It is a specification, and specifications are strict. You will meet refusals
for things a framework would have let you do. Declaring spends-money: yes
obliges you to say what makes two calls the same call. That is the point, and it
is still friction.
There is a vocabulary to learn. Not code, but 64 kinds and 545 fields. The core is 327 of them and the diagnostics name the exact line to type, but "no code" is not "nothing to learn".
The eval suite is work, and it is not optional. Every portability claim is
graded against your cases. Write none and PACT cannot tell you whether a model
swap changed anything — it will say UNDECIDED rather than guess.
Evals need a live model. Checking, loading, planning and digesting are all
offline. Grading is not: with nothing serving models, the verdict is UNDECIDED.
The second port is smaller. The Node port has no interceptor chain, no context policy, no approval policy and no durable resume. It says so on every run rather than degrading silently — but it is smaller.
Carried programs need a host. PACT declares the locked room; whatever runs your agents supplies it. Without one, a program-carrying workspace loads, says what it could not start, and does not run it.
| If you… | Use |
|---|---|
| are building one agent, on one framework, forever | that framework, directly |
| need behaviour PACT deliberately refuses — model-written orchestration, arbitrary code as a tool | a general-purpose framework |
| have no way to say what "working" means for your agent | anything; PACT's core value is the oracle, and you have not got one |
| need a hosted product with a UI and a dashboard today | a managed agent platform |
PACT is worth it when more than one of these is true: the author is not an engineer, the rules must be auditable, you will change model or framework, or somebody must be able to prove behaviour did not drift.
| PACT | Eve (Vercel) | LangGraph / CrewAI | A2A | MCP | |
|---|---|---|---|---|---|
| What it is | A portable contract | A filesystem convention | Frameworks | A wire protocol | A tool protocol |
| Authored in | YAML + Markdown | TS + a slot table | Python | — | — |
| Runtime reads your tree | ✅ as-is | ❌ generated ESM | n/a | n/a | n/a |
| Executes author code to load | ❌ never | ✅ build step | ✅ | n/a | n/a |
| Portable across frameworks | ✅ 7, measured | ❌ | ❌ | partly | n/a |
| Governance as data | ✅ | ❌ | ❌ | ❌ | ❌ |
| Fidelity measured | ✅ eval-graded | ❌ | ❌ | ❌ | ❌ |
| Air-gapped | ✅ | ❌ | varies | ✅ | varies |
PACT is not a competitor to A2A or MCP — it speaks both. pact card
publishes an A2A Agent Card; a tool's connect: names an MCP server, with the
consent gate and the published-tool digest that the protocol leaves to you. The
same connect: names an agent somewhere else (resource-kind: a2a-agent): its
actions: are the skills of its card the model may ask, the card is pinned like
a tool list (tool-snapshot-digest:), and its replies reach a model only fenced,
as text nobody here reviewed (a2a_bridge).
| What it shows | |
|---|---|
examples/refund-desk |
The flagship — supervisor, specialists, MCP tools, policy, evals, learning |
examples/answers-from-documents |
Retrieval with citation rules |
examples/mcp-desk |
A tool that reaches a real MCP server, with consent |
examples/patterns/ |
Eight orchestration patterns: debate, quorum, race, swarm, pipeline, weighted, escalation, first-answer |
examples/kernel/finance-first |
Topics, events and reactions: the finance desk is asked before money moves, the auditor is told after |
examples/language/ |
The same workspaces as one .pact file each: the smallest one there is, finance-first, and one beside a folder of documents |
examples/language/help-centre |
Everything together in four .pact files: named figures, a pattern and two desks made from it, two workflows written as programs with one calling the other, a reaction, an agent that listens, learning |
tests/trees/ |
Twenty fixtures, each the smallest tree that shows one thing |
tests/parity/ |
The Pydantic AI parity suite: one fixture per row Phase 1 compiles, and what each must show |
The harness in adapters/python runs a tree on any of the seven targets, and it is the
reference every other runtime is measured against. For production use on Pydantic AI there is
Bud Agent Flow: it compiles each agent of
a tree into a real Pydantic AI 2.54 agent, with no per-agent code and no change to Pydantic AI,
and runs it against a journal, so approvals survive restarts, a killed run resumes without
repeating a write, and every limit, loop, team, eval and port comes from the tree. It implements
261 of the spec's 306 fields, each proven by a test (the other 45 have no run-time behaviour),
and has been run against 19 complex multi-agent scenarios on two real models with every rule
holding. Where it and this harness differ, it says so, field by field.
The latest round, driven by running PACT trees in production on Pydantic AI, added these to
the spec. Every one is checked by pact check and held by the harness. The details are in
CHANGELOG.md.
| Added | What you write | What it does |
|---|---|---|
| One file instead of a folder | agent desk / uses zendesk, payments / at most 8 steps in a .pact file |
the same workspace, read by the same checker; pact write prints any folder as one file and pact build writes the folder back, and a test holds that every shipped workspace reads the same both ways (guide) |
| Variables, functions and loops | let limit = 200 USD · plan = call planner with brief: input.brief · for each item in … · decide / if billing · based on desk with domain: billing |
a workflow written as a program in a .pact file: the order of the lines is the wiring and a step's name is a variable; the same stages, checked by the same rules |
| When this happens, do that | reactions: with remind:, consult:, tell:, stop:; topics: on a tool; listens-for: on an agent |
a reminder, another agent asked first or told after, at a moment of the run or when the work turns to a topic; a call is held while the agent that asked to hear about it is consulted; a guess may advise and may not stop (guide) |
| Values known only at run time | {{run-inputs.brand}}, {{remembers.tone}} in instructions: |
the system fills them before the model reads the words; a hole that names nothing the agent declares is refused; \{{...}} keeps braces for the model |
| Fallback models | model: [qwen2.5-14b-instruct, qwen2.5-7b-instruct] |
the next model is tried only when a call fails at the provider; each one is held to allow-egress: and needs: |
| Answers checked before they count | checked-by: with must-contain:, must-say-one-of:, ...; checks-at-most: |
a failed check asks again with the reason; still failing, the run stops and names the rule |
| Typed action results | answers-with: on an action |
what a tool hands back, in the same shapes as an agent's answer; checked against a program's own answers-with: |
| Agents started at run time | teamwork.may-start: [catalogue, narrowed-new] with limits.starts-at-most: and nests-at-most: |
an agent may bring in others while it works, each start journaled, counted and paid from the request's pot; nothing is written into the tree |
| One grammar for schedules | every: on a port's timer |
read one way by every runtime; an hour is never guessed |
| Program bodies, specified | programs: with engine: python or wasm |
a Python body gets inputs and answers with its last line; a WASM body reads and writes one JSON object |
| Learning, scored by the host | learning.yaml |
a cycle is scored by the host's own runner from a kept baseline, and "held for a person" is its own outcome |
| REST services, imported | pact-import --as openapi api.yaml --out . |
one reviewed tool file per operation of an OpenAPI description; an operation that does not declare what it does is marked Needs a look and treated as a write that cannot be undone; the report names everything PACT cannot say |
| Blanks in an address | url: host/pets/{pet-id}?tag={tag} |
each blank is filled from the call's argument of that name; a blank no argument fills is refused |
| Voice, documents and embeddings | voice:, accepts: {message: audio}, embedded-by: |
the transcriber, speaker, voice and languages are held against the catalogue; a voice heard on this machine stays here behind a hosted model |
| Interfaces | tools/email.yaml with interface: yes; tools/gmail.yaml with implements: email |
one contract, several providers, picked per person or per run; an implementation may never ask less of a person than its interface |
| Jinja and turns | template-language: jinja; turns: from system, developer, user, agent, tool |
words that choose and repeat, in the dialect both ports render one way; worked conversations and multi-turn eval cases |
| Models chosen by evals | accept-when: [mmlu.score > 50, refund-tone.score >= 0.8, cost < 0.05 USD] |
a benchmark the catalogue records, a check of your own suite, what a case cost — every line held, before and after the candidate runs your cases (budflow choose-model) |
| Tool schemas from the server | pact-sync <server>; schemas-from: the-snapshot |
the server's own arguments, recorded and reviewed; which actions may be called stays written here |
| A social security number, hidden | anything that looks like a social security number in redaction.yaml |
a US one is hidden however it is parted; a bank account in lower case is a bank account too |
It also tightened what already existed: nobody is asked to approve a call that at-most-once
would refuse anyway; an amount nothing can read as a figure stops at the gate; a card number
written with no-break spaces or dots is redacted too; feel: is a latency band that stops
nothing. The Python adapters now run on Pydantic AI 2.54, import none of its private names,
never hand it a ceiling nobody wrote, and keep one table (pydantic_ai_registry.yaml) of what
each field becomes there, held by a parity suite (tests/parity/) that fails on any Pydantic AI
name the table has never seen. Two calls to one tool in one step are answered one by one; a
pattern a bundle brings is checked as a pattern; tools/catalog.yaml is the tool catalog. The core installs with three Python packages and the loader's wheel, and every other
framework is an extra.
| Design | Thesis, 29 binding decisions, FRD (120 requirements), 60-row refusal ledger — complete |
| Research | 14 source-grounded studies, ~15,750 lines, over 140 repos (~15 GB) + 57 papers |
| Code | Loader, diagnostics, schema engine, CLI, harness, resolver, evals, SLO — 5197 tests (1696 Rust + 3501 adapter), clippy clean, TypeScript type-checked |
| Adapters | All 7 named targets, proven against one shared conformance suite |
./scripts/test-all.sh # 5197 tests, Rust + 7 adapters, fully offlineWhat is still owed is not hidden — it lives in
the gap register, one row per gap, each
with its measurement. Two worth naming here: the Node port cannot report a
tool's bind: lines because its payload does not carry them, and distinct very
small decimals still collapse onto one digest.
SLOs report; they do not stop a run. That is deliberate and
slo.py says so in its first sentence.
docs/00-THESIS.md |
The argument, goals, objectives, acceptance criteria |
docs/01-DECISIONS.md |
29 binding decisions — read this before proposing anything |
docs/30-FRD.md |
120 functional requirements, each traced to its justification |
docs/97-PRIMITIVES.md |
The seven building blocks every keyword reduces to, and the eight laws |
docs/99-THE-PACT-LANGUAGE.md |
The PACT language and reactions: the design, and where the build differs from it |
docs/50-NOT-COPIED.md |
The refusal ledger — what was deliberately not copied, and what would bring each back |
docs/70-PRODUCTION-GAP-REGISTER.md |
Every gap between claim and code, with its measurement |
research/notes/README.md |
The research index, with the 12 findings that changed the design |
crates/ Rust core
pact-diag/ diagnostics — a fix is mandatory by construction
pact-doc/ span-preserving YAML / JSON / Markdown
pact-lang/ the PACT language: one `.pact` file, read and printed
pact-loader/ the Expansion Rule, and every check a schema cannot make
pact-schema/ validation; the schema is data, not code
pact-cli/ check · show · waits · discover · card · write · build — never runs your code
pyproject.toml the `pact-loader` wheel: `pact-cli`'s binary, built by maturin
adapters/python/ reference harness, six transports, resolver, evals, learning
adapters/typescript/ the second port — Node, smaller on purpose, and says so
spec/schema.yaml the specification, written in PACT (4,792 lines)
spec/loops/ six loop shapes, authored the way anybody's are
examples/ the worked example, plus eight orchestration patterns
tests/trees/ twenty fixtures, one idea each
tests/parity/ the Pydantic AI parity suite, one folder per row
docs/ thesis · decisions · FRD · plan · refusals · gaps
PACT and pact.dev/v1 supersede bud.dev/v1. Consumed natively by
gaia-ai-runtime; deliberately independent of
AgentZero, which owns agent-collective routing and scale.