Skip to content
BudEcosystemPublic

About

Portable Agent Contract & Topology - A Standard and framework for Portable agents.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

PACT

Portable Agent Contract & Topology

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-desk
OK — 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.


Contents

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

Why PACT

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
Loading

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.

The idea

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
Loading

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.


Install

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 environment

Two 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.


Tutorial: from nothing to a governed agent

Four steps. Every command and every output below is real — run them yourself.

Step 1 — an agent (30 seconds)

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-agent
OK — my-agent loaded cleanly (8 settings).

Step 2 — give it a tool

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:
  - orders

Check 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.

Step 3 — make a person approve it

# 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-so

Now ask what a runtime must be prepared to wait for — before running anything:

pact waits my-agent
wait: may-we-refund | reason: needs-approval | asked of: ['the returns team']
     | within: 4h | if nobody answers: stop-and-say-so

Step 4 — what actually happens

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"
Loading

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.


The PACT language: the same agent in one file

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.pact
OK — my-agent.pact loaded cleanly (62 settings).

Three kinds of line

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

Variables, functions and loops

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.

Going between the file and the folder

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 for
Wrote 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.

Why the two cannot drift

  • One reader for values. A setting in a .pact file 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 write writes 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 .pact file 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.


When something happens: events and reactions

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

What a reaction can wait for

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.

Topics, and agents that listen

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.

What a run looks like

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
Loading

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.

What holds

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.


The one rule

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.


What you can express

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.

CapabilityYou writeWhat it buys
Toolstools/*.yaml, uses:HTTP, MCP servers, or another agent — one vocabulary
Written proceduresskills/*/SKILL.mdPolicy in Markdown a compliance team can read and edit
Documentsknowledge/*/documents/A corpus to look things up in, with citation rules
Human approvalpolicies/, questions/The run parks, serialises, and resumes — across a crash
Ceilingslimits:Steps, tool calls, tokens, money, wall-clock — enforced, not suggested
Redactionredaction.yamlCard numbers never leave, in plain sentences
Rules mid-runinterceptors/Hide, stop, redirect or rewrite — a closed vocabulary of eight sentences
Shape of thinkingloops/ReAct, plan-then-do, reflexion, tree-of-thought, and your own
Teamsteam:, teamwork:Delegation, quorum, races, budget shares — eight worked patterns ship
Memoryremembers:A variable a run reads into a call and writes back from one
Exact logicprograms/WebAssembly in a locked room, with fuel and a consent gate
Runtime pluginsplugins/*.yaml, plugins:Code your runtime loads, named with a version range — refused or reported where it cannot be loaded
How you knowevals/The correctness oracle for portability, model swaps and learning
Self-improvementlearning.yamlChanges arrive as reviewable diffs, class-gated, never silent

Two things worth seeing

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.


How it works

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
Loading

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.

Portability, measured

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.


Advantages

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.


Disadvantages, and when not to use PACT

Written plainly, because a specification that only lists its strengths is marketing.

Costs you will actually pay

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.

When to use something else

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.


Compared with

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).


Examples

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

Runtimes

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.


What's new

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.


Status

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 offline

What 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.


Documents

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

Layout

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

Naming

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.

About

Portable Agent Contract & Topology - A Standard and framework for Portable agents.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages