gRPC, without a second toolchain¶
AAT v0.3.0 runs gRPC. The interesting part is what it did not add: no second plan format, no second runner, no second archive viewer, and nothing new to learn if you already have a project. A graph node names a method instead of a path, a template sends a message instead of a body, and everything downstream — the wiring between steps, the assertions, the retries, the cleanup, the archive, the web UI, the MCP server — carries on as though nothing had happened.
Here is a plan that opens a cart over HTTP, checks it out over HTTP, and then charges and refunds the resulting order over gRPC:
[1/5] cart (createCart) 201 0ms
[2/5] item (addItem) 201 0ms
[3/5] order (checkoutCart) 201 0ms
[4/5] charge OK 191ms
[5/5] refund OK 0ms
PASSED (5/5 steps, 192ms)
The protocol switch is the status column, and that is the whole of it. Three steps report 201; two report OK, because gRPC has no 201 and naming the code the server actually sent beats inventing a number for it. Nothing else about that run — not the plan, not the wiring between the steps, not the archive it wrote — knows that the last two calls left over a different protocol.
Sixty seconds¶
No signup, no account, no network. The sandbox serves the shop over HTTP on :8765 and the same payments API over gRPC on :8767:
brew install gburgyan/tap/aat # or a release archive, Docker, go install
aat-sandbox init --example grpc-payments payments
cd payments
aat-sandbox serve & # HTTP :8765, payments :8766, gRPC :8767
aat validate --strict # checks the gRPC nodes offline
aat run plan charge-and-refund
That is the run above. aat run plan declined-card is the negative case, and make example-grpc is what CI runs against the same project on every push.
The order id does not know¶
A gRPC node is an ordinary node with a proto: reference where an HTTP node has an oas: one. Its inputs are wired the way every other node's inputs are wired:
paymentCharge:
adapter: paymentCharge
proto: shop.v1.Payments/Charge
requires: [orderPlaced]
inputs:
- name: orderId
type: string
default: {from: checkoutCart.orderId}
- name: amount
type: integer
default: {from: checkoutCart.total}
outputs:
- name: paymentId
type: string
satisfies: [orderPaid]
checkoutCart is an HTTP node. Its response was JSON over REST; this request is protobuf over gRPC; the from: between them is the same from: you would write between two HTTP steps. The step boundary does not care what protocol either side speaks, because by the time a value reaches it, it is a value.
That runs in both directions. The refund step, two gRPC calls after the charge, asserts against what the charge returned:
- id: refund
node: paymentRefund
isGoal: true
assertions:
mechanical:
- type: status
expect: OK
- type: fieldEquals
path: $.paymentId
value: "{{charge.paymentId}}"
- type: predicate
expr: refundedAmount > 0
An id extracted from a protobuf message, compared against an id extracted from another protobuf message, by an assertion engine that has never heard of protobuf. Messages cross into the rest of AAT as JSON, so extraction, predicates, assertions, error detection, and archives needed no gRPC knowledge added to them at all.
A template names a method, not a path¶
The translation is four rows long:
| HTTP | gRPC |
|---|---|
method: + path: |
rpc: |
headers: |
metadata: |
body: |
message: |
form: |
— |
Which makes a template look like this:
adapter: paymentCharge
protocol: grpc
request:
rpc: shop.v1.Payments/Charge
message: |
{
"orderId": "{{orderId}}",
"amount": "{{amount}}",
"currency": "{{currency}}"
}
response:
extract:
paymentId: paymentId
paymentStatus: status
Metadata carries what headers carry, credentials included, so oauth2, apikey, and bearer need no new configuration — every auth type already resolved to a name and a value, and on a gRPC call that value travels as metadata instead of as a header. In the demo project the shop authenticates with a bearer token and the payments service takes an API key, and the environment expresses that as one overrides: entry pointing at grpc://localhost:8767. Routing a node to another host is a mechanism that predates gRPC; pointing it at a grpc:// target is the only new part.
Mixing the two vocabularies is an error that says so by name: request.path belongs to an HTTP template; a gRPC request names its rpc and sends a message.
Statuses have names¶
INVALID_ARGUMENT, FAILED_PRECONDITION, and OUT_OF_RANGE are all HTTP 400. A negative test that expects one of them should not quietly pass on another, and a number cannot tell them apart. So statuses are asserted by name. A step that should succeed says so:
assertions:
mechanical:
- type: status
expect: OK
And a step that should be refused names the refusal it expects:
expectFailure:
status: [INVALID_ARGUMENT]
description: the issuer declines this card every time
Writing both on one step is a contradiction, and as of this release it is one AAT catches rather than resolves silently. The demo's declined card is the second kind. The sandbox always refuses card 4000000000000002, and the plan names the code it expects:
[1/4] cart (createCart) 201 0ms
[2/4] item (addItem) 201 0ms
[3/4] order (checkoutCart) 201 0ms
[4/4] charge INVALID_ARGUMENT 110ms
PASSED (4/4 steps, 111ms)
A passing run, because the refusal is what was asked for. Retry rules read the same way, which is where the precision earns its keep: on: [ABORTED] retries a contended write, while failOn: [FAILED_PRECONDITION] gives up immediately on a state error that shares HTTP 400 with the INVALID_ARGUMENT next to it. Numbers still work everywhere names do, so a plan written against status: [404] matches HTTP 404 and gRPC NOT_FOUND alike and can run against either protocol.
Checked before anything is sent¶
A descriptor set — what protoc --descriptor_set_out and buf build -o write — is the contract, and aat validate reads it offline, with no server running:
Manifest: OK (project: grpc-payments)
Environment: OK (single environment)
Graph structure: OK (5 nodes)
Protobuf validation: OK (2 gRPC nodes)
Adapter outputs: OK (5 templates)
Node protocols: OK (2 gRPC, 3 HTTP)
Template inputs: OK
Plans: OK (2 files)
Project validation: PASSED
Those two gRPC lines are the ones that matter here. Misspell a method and it is caught before a cart exists:
node "paymentCharge": service "shop.v1.Payments" has no method "Charrge"
Read a field the response does not have, and the error names the message that would have had to declare it:
node "paymentCharge": output "paymentStatus" reads "statuss": shop.v1.Payment does not declare "statuss"
And the one that actually catches people, because protobuf field names and their JSON encoding disagree:
node "paymentCharge": output "paymentStatus" reads "order_status", but the response encodes that field as "orderStatus": read "orderStatus"
Point a gRPC node at an HTTP template — the natural mistake of forgetting protocol: grpc — and validation says that too, rather than letting it fail three steps into a run with a cart and an order already created:
node "paymentCharge": declares proto shop.v1.Payment/Charge but its template "paymentCharge" sends shop.v1.Payments/Charge; the node and its template name the same method
Streaming methods are refused outright. A step is one request and one response, so a server-streaming method is not something a step can be; server-streaming may come later, and bidirectional will not. In a bidirectional stream the next message depends on the previous reply, which makes it a program rather than a step, and a step in AAT is a declarative function of its resolved inputs.
The thing that will bite you¶
proto3's JSON encoding writes a 64-bit integer as a string. Not a number that happens to be quoted somewhere — a string, always:
{
"paymentId": "pay_0001",
"orderId": "ord_0001",
"status": "captured",
"amount": "20082",
"amountDisplay": "$200.82",
"currency": "USD"
}
That is the charge step's real response, and amount is an int64. So fieldEquals against 20082 fails and against "20082" passes, while amount > 100 works either way, because an ordering comparison reads a decimal string by value. That last part is a v0.3.0 fix: the guide promised it and the evaluator did not deliver it, failing with cannot compare string with float64 until a plan quoted the number.
Two more worth knowing before you write your first message. Zero values are present rather than omitted — 0, "", false, [], and {} all appear in a response — which is what makes fieldAbsent mean something. And only Google's own well-known types get special JSON forms, so a message that merely copies one is an ordinary message: Qdrant's qdrant.Value, a fork of google.protobuf.Value, arrives as {"stringValue": "Berlin"} and not as "Berlin".
Where it was stress-tested¶
The sandbox proves the plumbing. aat-qdrant proves the rest: all 52 of Qdrant's public unary gRPC methods, as 77 operations, 38 plans, and 6 layers, passing 38/38 in about 70 seconds against a pinned local container.

It was built against protobuf nobody on the AAT side wrote — oneofs for ids, vectors, and query kinds, payloads that are map<string, Value>, 64-bit counts arriving as strings, and a pagination cursor that is itself a message — with every node checked offline against Qdrant's 17 published .proto files, vendored unchanged. Each expected failure names its gRPC status and the exact message, which is how the plans can assert that a read-only key on a write is PERMISSION_DENIED where a missing key is UNAUTHENTICATED, and that a rate-limited read puts retry-after in the trailers rather than the headers.
Twelve changes to AAT came out of building it, each made as the gap turned up. That is the point of building the project rather than writing about the feature.
Also in v0.3.0¶
Not everything in the release is gRPC, and two items change behaviour you may be relying on:
fieldEqualscompares a list or an object by its structure. Anything but a scalar used to be compared by its printed form, so{population: 3645000}failed against the JSON{"population": 3645000}and["4"]passed against[4]. Lists and objects now match element by element and key by key, numbers by value and strings only as strings, at any depth.["4"]no longer equals[4].--override NODE=URLkeeps the node's own credential. It used to take the environment's top-level auth even when anoverrides:entry gave that node another, so overriding one node's URL could send the wrong service's token. A flag now changes the URL and nothing else.- An output can echo an input.
fromInput: <input>makes a node output be the value the step sent, for an API where the client names what it creates and the reply does not say it back — Qdrant's create-collection answers{"result": true}— so a later step and the node's cleanup can read the name like any other output. - Ctrl-C ends
aat webat once, instead of waiting out a five-second shutdown on connections a browser left open and then exiting withcontext deadline exceeded. aat-sandbox servelistens on a third port,:8767, for the gRPC payments service.- The binary is bigger: about 28 MB to about 44 MB, which is what
google.golang.org/grpcandgoogle.golang.org/protobufcost.
What it isn't¶
Unary methods only. There is no aat generate from a descriptor set yet, the way there is from an OpenAPI spec, so a gRPC graph is written by hand or by an assistant reading the protos. No gRPC-Web and no Connect. The call timeout is a fixed 30 seconds and not yet configurable. gRPC's own retry and load-balancing policies are deliberately off, because AAT retries steps itself and two retry layers disagreeing is worse than one. It is pre-1.0 with one maintainer, and the formats may still change.
Get it¶
brew install gburgyan/tap/aat # or a release archive, Docker, go install
aat-sandbox init --example grpc-payments payments
cd payments
aat-sandbox serve &
aat run plan charge-and-refund
aat web view latest # the run, step by step
The gRPC guide covers descriptor sets, routing, TLS and mutual TLS, status names, and the proto3 JSON rules most likely to surprise a plan author. The changelog has the full v0.3.0 list, and aat-qdrant is the worked example to steal from.
One graph, one run, two protocols.