AXOWORKS Intelligence Logs
AxoWorks Strategy · Zero-Click Marketing, Part 3 of 3 · Field Notes

Zero-Click Marketing (Part 3): The Chef Can Discuss the Dish — What Agent2Agent Uncovered in Our Own Stack

We taught our MCP storefront to hold a conversation instead of taking an order. It worked. Then we attacked the whole stack with 215+ hostile probes and found seven worms underneath the thing we had just published as hardened.
Classification: Field Notes · Published: 2026-09-15 · Revision 1 · Status: verified against the live production endpoint · Canon: axoworks.com/articles/the-chef-can-discuss-the-dish

The short answer: Part 1 built a machine-callable storefront, and Part 2 attacked it. Part 3’s job was to let a visiting agent converse rather than merely order, so we implemented Google’s Agent2Agent protocol: an agent card at /.well-known/agent-card.json, an endpoint at /api/a2a, message/send as the primary method, task records in Netlify Blobs with a one-hour TTL. It works. Getting there meant running 215+ hostile probes across the engagement, and the probes found seven defects — none of them in the model, all of them in the plumbing, including a transcript data plane reachable with the publishable key we ship in every browser bundle, an ownership cookie the server would mint for anyone who asked, a signing key that turned out to be a public credential, and a security fix that silently gagged both of our agent surfaces. This is the record: what shipped, what broke, the numbers, and what is still open.


TL;DR


01 / The move to A2A: teaching the storefront to talk

An A2A client does not order, it converses — and conversation is the only way to narrow an AEC question that arrives under-specified.

The gap was easy to state and harder to fix. Our MCP endpoint at https://axoworks.com/api/mcp was AI→AI in the sense Part 1 claimed: a machine on the serving side, grounded in our own corpus, answering in our own voice. But from a visiting agent’s point of view the surface was still a counter. It picked a tool, supplied arguments, and received a result. If it had the wrong argument — an ambiguous site, a scope it had not read, a revision question that needed a follow-up — there was nothing to do but call again and hope.

That is a fine interface for a lookup and a bad one for a judgment. AEC’s first question is rarely a lookup. It is conditional: can this lot yield four units, what changes if the setbacks are tighter than we assumed, what would you need to see before you would stake your name on it. You cannot answer those with a parameter.

So we implemented Google’s Agent2Agent protocol. The difference in one line, from the owner’s own comment on A2A, posted on LinkedIn on 9/13/26:

“Our MCP endpoint was always AI-to-AI. Problem? Today’s visiting agents lack the skills, let alone the smarts. Just automaton gig drivers picking up from a take-out counter. A2A tells them the chef can discuss the dish.”

That is the whole thesis, and it is also the whole problem. A counter has a queue. A conversation has a transcript, an owner, a life cycle, and a set of writers. We added all four at once, into a stack that had spent the previous engagement learning that the plumbing is where the damage lives.

It is worth saying plainly what an A2A implementation is for a firm our size, because it is not novelty. It is the difference between a machine that can retrieve what we already wrote down and a machine that can decline to guess. A tool call accepts the premise of the question. A conversation can question it, ask for the missing input, and say “I cannot source a determination for that jurisdiction” instead of returning the closest plausible number. In a business where a wrong answer is a liability rather than a bad impression, the ability to be asked again is a feature.


02 / What actually shipped: the A2A surface

One agent card, one endpoint, three methods plus a legacy dialect, tasks with ids and owners, and a write gate that only accepts structured invocations.

Here is the shipped surface in full, because a machine will hold us to every line of it:

SurfaceValue
Agent card/.well-known/agent-card.json, with an alias at agent.json and a resolvable agent-card.schema.json
Endpoint/api/a2a — JSON-RPC 2.0
Protocol version0.1.0 (A2A_PROTOCOL_VERSION)
Methodsmessage/send (primary) · tasks/get · tasks/cancel — plus the legacy tasks.create dialect, accepted rather than rejected
Task idsa2a-task-<uuid>
Task recordsNetlify Blobs, owner-keyed by a SHA-256 hash of the platform-supplied client IP, 1-hour TTL
Write gatestructured invocations only — free text never writes to the lead pipeline
Errorssanitised and shape-stable; -32001 for an unknown task, -32002 for a foreign one

Three choices in that table deserve a sentence each, because each one is a decision we would make again.

The legacy dialect is accepted, not rejected. tasks.create is not the dialect we would have chosen, but a client that speaks it is a client that is trying to reach us. Refusing a working method on stylistic grounds is exactly the kind of purism that loses a machine relationship permanently, and Part 2 already established what a broken tool teaches a visiting agent.

Task ownership is keyed to a hashed client IP, with an hour of life. A task is a record about one conversation, so it needs an owner and an expiry. Owner-keying means tasks/get with someone else’s id returns -32002 rather than a transcript, and a one-hour TTL means the store cannot quietly accumulate other people’s conversations for a year. Hashing the platform-supplied address rather than storing it means the key is not itself a piece of personal data.

The write gate only accepts structure. book_consultation will not fire from free text. It requires a structured invocation — a data part or metadata.arguments carrying name, email and scope. That was the direct descendant of Part 2’s lesson about capability-level gates: a sentence that sounds like a booking is not a booking, and the one tool that reaches a human inbox should not be reachable by prose.

What we did not add, and should say out loud: streaming and push notifications are off, and that is a deliberate reduction of surface rather than a limitation we are hiding. Every capability you advertise in an agent card is a capability you have to secure and pay for.


03 / The stress test: 94, 80, 41 — and the crash we shipped

A 94-probe hostile audit scored us 7.1/10. The 80-probe retest after vendor remediation scored 6.8/10 and caught a production crash. A second retest with 41 probes scored 8.7/10. Then the A2A/MCP audit proper took the engagement past 215+ probes.

We do not run a self-assessment and call it evidence. The sequence is the argument, so here it is in order.

WaveProbesScoreWhat it caught
Hostile audit of The Concierge947.1 / 10The baseline. Conversations held; plumbing did not.
Retest after vendor remediation806.8 / 10A production crash: mappedEventType is not defined. The score went down because the fixes created a defect the audit did not have.
Second retest418.7 / 10Regression validation against the second hardening pass.
A2A / MCP audit proper215+ probes across the engagementThe seven worms below.

The 6.8 is the number we would put on a slide, and not because it is impressive. It is the only score in the table that measures the system as it ran rather than the system as we intended to ship it. The 80-probe wave verified our remediation claim-by-claim and, in passing, found that we had broken the write path in the act of hardening it — a reference to a variable before assignment, surfacing as a runtime error instead of a booking.

That pattern is now three for three across this series. Part 2’s worst defect was created by Part 2’s first security patch. This engagement’s came from a patch too. And as you will see in Worm 06, the pattern was not done with us.

Invite the adversary back, and keep inviting them. A fix that has not been re-attacked is a claim about a commit, and we have already published what those are worth.


04 / Worm 01 — the data plane was open to the public key

Our transcript RPCs were SECURITY DEFINER and the anon role held EXECUTE. The Supabase publishable key — the one shipped in every browser bundle — could read and write any session’s transcript directly.

This is the finding of the engagement, and it is the one we would hand to any firm building on Supabase or anything shaped like it.

Row-level security was working. It was working so well that it produced the wrong conclusion: a raw SELECT against the transcript tables using the publishable key returned 0 rows, which is what a locked table looks like. The trouble was that our transcripts were not read through the table. They were read through RPC functions, and those functions were SECURITY DEFINER with EXECUTE granted to anon. RLS is a policy on the table. It does not sit in front of a function that has been granted permission to run as its definer. The lock was on the front door of a building with a service corridor beside it.

The evidence was two requests, and the second one is the one we would keep.

The fix is three lines of policy and one architectural correction: revoke EXECUTE from anon and public, grant it to service_role, and move the server onto a service-role client with no anon fallback. The last clause is doing the real work. A fallback is how this defect gets reintroduced by a helpful future commit.

Design principle: row-level security is a table control, not a function control. If you expose data through an RPC, the grant on that function is your access control.


05 / Worm 02 — the ownership cookie was minted on demand

The endpoint HMAC-signed any session id the caller named. The ownership check was circular: the signature proved the server had signed the id, never that the caller owned the session.

This one is elegant in the way the worst bugs are. The cookie verification code was correct. It recomputed the HMAC, compared it, and rejected anything that did not match. It was a real check, doing real work, on a premise that made it worthless: we signed whatever we were asked to sign, for any session id, with no requirement that the caller have any relationship to that session.

So the signature was genuine and the ownership was fiction. A verification routine can only prove what it was designed to prove, and ours was designed to prove “we issued this” while the surrounding code treated it as proof that “this is yours.” Those are different sentences, and the gap between them was a session id.

The fix is a precondition, not a stronger signature: mint a cookie only for a verified or bootstrap ownership decision, and fail closed when the decision is unknown. An unknown owner is a refusal, not a default.

Design principle: a signature proves provenance, not entitlement. If you sign on request, you have built an autograph stand, not a lock.


06 / Worm 03 — the writes were not gated

The default tier’s exchange logger wrote into whatever session it was handed. A third writer existed in the serverless email and calendar functions.

This is the finding that should worry anyone running a conversational surface, because the mechanism is not exotic and the consequence is not small.

An exchange logger is bookkeeping: it records that a turn happened. Ours accepted a session id as an argument and wrote into it. No ownership decision, no gate, no question asked. Which means the stored transcript — the thing the model reads back as memory on the next turn — was writable by anyone who could name a session. That is persistent indirect prompt injection with a database behind it: plant the text once, and it is re-read into the model’s context on every subsequent turn, indefinitely, without the attacker ever returning.

A third writer lived in the serverless email and calendar functions, doing session-keyed writes from a code path nobody had thought of as part of the conversation at all. Two doors, one room, and this time neither door had the rules.

The fix has two halves and both matter: gate every writer on the ownership decision, and remove the session-keyed writes from the serverless functions entirely. A writer that cannot name a session cannot write into one.

Part 2 published the sentence “validation belongs at the capability, not the caller.” Worm 03 is that sentence catching us out on a capability we had not counted as one. A logger does not feel like a capability. It is one — it writes to the memory your model reads.


07 / Worm 04 — the signing key was a public-by-design credential

Recomputing the HMAC of a live-issued cookie identified the deployment’s signing key as the Supabase publishable key. Anyone holding it could forge an ownership cookie for any session.

Measured, not guessed. We took a cookie the system had issued live, recomputed its HMAC against candidate keys, and the key that matched was the Supabase publishable key — the credential designed to be public, shipped in the browser bundle, sitting in every visitor’s network tab.

Put Worm 04 next to Worm 02 and the shape is clear. Worm 02 was a server that would sign anything. Worm 04 was a key anyone could sign with. Either one alone is a serious finding. Together they meant the ownership model was decorative: not only would the server mint an ownership cookie for whatever session you named, but the secret used to verify it was in the page source. The signature was real, the verification was real, and the entire structure was public.

The fix is not clever and should not be: a dedicated SESSION_SECRET, no fallbacks to any other key, and v1.-versioned tokens so that a future rotation is a format change rather than an archaeology project. The version prefix is the part people skip. It is the part that makes the next fix cheap.

Design principle: a credential designed to be public can never be a secret, no matter how it is used.


08 / Worm 05 — every user turn was stored twice

Two writers, one before the model call and one at reply time. Across the 600 most recent message rows, user rows outnumbered assistant rows 362 : 238.

This one is not a security finding. It is worse in a quiet way: it was eating the product while nobody was looking.

We had two writers of the user turn. One recorded the message before the model was called, and one recorded it alongside the reply. Both were correct in isolation. Together, every user message landed twice — and because the reader counted rows rather than turns, each exchange consumed three rows of a twenty-row memory window instead of two.

The audit quantified it across the 600 most recent message rows, spanning 182 sessions:

The fix is a single writer of the user turn, plus a collapse of adjacent duplicates at read time. The read-side collapse matters as much as the write-side fix, because the database already contained the duplicates and a dedupe that only works going forward does not repair a single existing conversation.

A duplicate write is not a storage problem. It is a shorter memory. Nothing threw, no dashboard went red, and the only visible symptom was a storefront that seemed slightly forgetful — which is exactly the kind of defect that survives a release and gets attributed to the model.


09 / Worm 06 — the fix for Worm 01 silently gagged the agent surfaces

A2A sessions held 18 rows: 12 protocol_activation, 5 tool_use, 1 lead_capture, and 0 message. The audit scaffolding around a conversation, with the conversation itself missing.

This is the best worm of the lot, and it is the one we would frame.

We were checking that the revoke from Worm 01 had landed, so we looked at what our agent surfaces were actually storing. The A2A sessions told the story in four numbers. Twelve rows recording that the protocol had been activated. Five rows recording tool use. One lead capture. And zero message rows. The system was faithfully logging everything about a conversation that was not being saved. MCP was cleaner still: zero rows of any kind.

The cause was the fix itself. Our agent entry points were still authenticating to the database with the anon key, so the revoke we had just shipped — the correct revoke, the one that closed Worm 01 — refused their writes. The surrounding audit rows kept landing perfectly because the logger, separately, already used a service-role client. So the instrumentation reported success, the perimeter reported success, and the conversation was gone.

Read that sequence again, because it is the whole lesson of this series in one incident: a correct security fix, verified by the wrong instrument. The audit rows were not evidence that the conversation persisted. They were evidence that the logger worked. We had confirmed the telemetry and assumed the product.

The fix: point both agent entry points at the server-side service-role client, and verify live that a question and its reply now persist. Not verify the logs. Verify the rows.

Part 2 closed with the observation that our worst defect came from our own first security patch. We published that sentence. Then we did it again, in a new organ, and it took a row-count to notice.


10 / Worm 07 — two more worth a line each

Neither is exotic. Both are the kind of thing that lives in a codebase for a year because it never looks like the problem.

An unauthenticated email path took its recipient from the user’s own message. An address that appeared inside the text of a request could become the recipient of a mail the system sent. The message was the input and the routing target at the same time, which is a small design slip with an obvious abuse story attached. It is now throttled: a 10-minute recipient cooldown, 5 sends per caller per hour, and 100 per day.

An internal-token gate accepted a publicly-known hardcoded constant — on an endpoint that proxies our own model keys. A gate whose secret is in the source is not a gate; it is a comment. Rotating it into configuration is the fix, and the reason it gets a line here rather than a section is that it has no interesting failure mode. It was simply open.


11 / Updating the vector: an embedding is only as good as the row it was built from

Deep-passage queries went 0/4 → 4/4 once long documents were chunked into 58 rows across 11 documents. The retriever was never the problem. The rows were.

While the worm hunt was running, we audited the retrieval index, and the result is the cleanest before-and-after in the engagement.

MetricBeforeAfter
Deep-passage queries0 / 44 / 4
Corpus149 rows
New chunk rows58 across 11 documents
Minimum cosine0.999994
Coverage96.9%
Embed character limit2,000 (stale)8,000 (MCP_EMBED_MAX_CHARS)

Four queries designed to require a passage buried deep inside a long document returned nothing before the change and returned the right passage after it. Nothing about the embedding model, the distance function, or the query changed. What changed was that a long document stopped being one row.

One row per document is a comfortable design until the document is longer than the embedder can see. Past that boundary, the tail of the document is not badly represented — it is not represented at all, and the failure is silent, because the query still returns something with a plausible score. Chunking long rows into 58 rows across 11 documents is what turned four failures into four answers.

Two details are worth stealing. First, we embed one blended vector per rowtitle | url-stripped content | tags — rather than separate fields, because a row is a unit of meaning, not a record with columns. Second, we found a stale 2,000-character cap that disagreed with the real embed limit. Cap and model had drifted apart, so a slice of every long row was being quietly truncated by a number nobody had re-checked. It is now MCP_EMBED_MAX_CHARS, default 8,000, and the point of naming it is that the next time the model changes, the cap is a knob rather than a surprise.

An embedding is only as good as the row it was built from. If your chunking is wrong, your model is not failing — it is working perfectly on the wrong input.


12 / The three-LLM day: HTTP 200, then zero bytes

DeepSeek’s US service went down, our fallback never fired, and chat was down for every visitor while a healthy alternative sat one throw away. The failure mode was silence.

Three large language models are in this story, and only one of them broke.

DeepSeek is the primary. Its US service went down, and the way it went down is the entire finding: HTTP 200 response headers in about 0.4 seconds, then zero bytes. The connection opened, the status was a success, the body never arrived. MiniMax is the fallback, healthy the whole time. Gemini on Antigravity is the third — it comes back in the next section, doing work we did not do ourselves.

Here is why the fallback could not save us. Our fallback logic was exception-driven: if the primary call throws, try the other provider. Nothing threw. We received a textbook success status, a well-formed response object, and no content. So the code sat there waiting for a body that was never coming, and the edge runtime did what edge runtimes do — it killed the function at its ~50-second wall clock. Every visitor got a dead chat surface. Not an error page. A hang.

The correct diagnosis is one sentence: we had written a fallback for errors and shipped it against a failure that does not error. A try/catch around the provider call would not have helped either, because nothing errored — the request looked fine from every angle except the one we were not measuring, so the fix had to be a budget on silence rather than a handler for exceptions.

The fix converts silence into a throw:

The reason this section is in the article, rather than in an internal changelog, is that the bug class is not a provider bug. Any streaming dependency can return a success header and then nothing: a proxy, a queue, a cold container, a model API being rebalanced. If your error handling only fires on errors, the failures that will actually take you down are the ones that politely say 200.


13 / The image sanitiser could delete but never correct

Credit first: this finding and this fix came from Gemini on Antigravity. A wrong-but-allow-listed image URL passed our output sanitiser untouched, because that gate strips disallowed URLs and never corrects a wrong one.

The defect is subtle and it is worth stating precisely, because it is a class of bug that an allow-list makes easy to write.

On the agent surfaces, the model could emit an image with the correct label and the wrong URL — writing something like ![BaSa](<another project's genuine Cloudinary asset>). Every part of that is legitimate in isolation: the label is a real project of ours, and the URL points at a real asset on one of our own allow-listed Cloudinary accounts. The sanitiser inspected it and passed it, correctly by its own rules. The gate’s job was to strip URLs that were not allow-listed, and this one was. So a page about one project displayed another project’s image, with a clean security log.

An allow-list tells you the URL is yours. It never tells you the URL is right. Those are different questions, and a sanitiser that answers the first one will keep passing the second one forever.

Gemini fixed it by removing the duplication rather than adding a check. The title→URL matcher that already existed in the web streaming path was extracted into a shared module, so the website and the agent surfaces now run one implementation. The agent replies are then healed before sanitisation.

That ordering is not a style preference, and it deserves its own sentence: the sanitiser deletes a disallowed image together with its alt text. So sanitising first destroys the very ![BaSa] label the healer matches on. Fix the image before the gate closes, or the gate removes your ability to fix it. We have now learned “order of operations is a security property” three times in this series.

Refactoring a matcher out of a path that live visitors depend on is exactly the change you are not allowed to get wrong by inspection, so it was proved differentially: the new implementation and the pre-change implementation were run side by side over the same matrix and their outputs compared byte for byte. 72 of 72 identical, across 36 text and context cases × {direct call, real 3-chunk streaming round trip}. That is a behavioural equivalence claim backed by execution, not a promise attached to a diff.

Two closing details, both hand-offs to the rest of the stack. The fix also closed the spaced image form![BaSa] (url), with the whitespace that a markdown parser forgives and a naive matcher does not. And we confirmed against the live library that all 35 image-bearing rows sit on allow-listed Cloudinary accounts, which is what makes the healer sound rather than merely clever: a heal target always exists, so the correction is never a guess about which image was meant.


14 / FAQ

Q: What is Agent2Agent (A2A), and how is it different from MCP?
A: MCP is how an agent calls a tool. A2A is how an agent talks to another agent. Both ride JSON-RPC 2.0 over HTTP, and on our stack both resolve to the same three skills, but the verbs are different. An MCP client orders: it picks a tool, supplies arguments, gets a result. An A2A client sends a message and receives a task. That task has an id, a life cycle, a fetch method, a cancel method, and an owner. Our MCP endpoint was always AI→AI in the sense that a machine was answering, but a visiting agent could order and not converse. A2A is what let it converse.

Q: Why would an AEC firm implement A2A at all?
A: Because in AEC the first real question is never a lookup. It is conditional: can this lot yield four units, what changes if the setbacks are tighter than we assumed, what would you need to see before you would stake your name on it. A tool call returns a value. A conversation can narrow scope, ask for the missing input, and refuse to answer what it cannot source. The owner put it best: “Just automaton gig drivers picking up from a take-out counter. A2A tells them the chef can discuss the dish.”

Q: Does a visiting agent need an account to use the A2A endpoint?
A: No login was added for A2A. Task ownership is keyed to the platform-supplied client IP, hashed with SHA-256, and a task record expires after one hour. Read someone else’s task id and you get -32002, a foreign task, not a transcript; a task id that never existed returns -32001. The one thing the write gate insists on is structure: book_consultation will not fire from free text, because free text never writes to the lead pipeline. It requires a structured invocation — a data part or metadata.arguments carrying name, email and scope.

Q: How do you know the image fix did not change how the site behaves?
A: The new implementation and the pre-change implementation were run side by side over the same input matrix and their outputs compared byte for byte: 72 of 72 identical, across 36 text and context cases times two invocations, a direct call and a real three-chunk streaming round trip. That is a differential test, not a code review, and it is the only reason we were willing to move a matcher out of the web streaming path that real visitors depend on.

Q: What happens when your primary model provider goes down mid-stream?
A: Ours failed quietly and took the chat surface down for every visitor while a healthy fallback sat one throw away. The provider returned HTTP 200 headers in about 0.4 seconds and then zero bytes. Nothing threw, so the fallback never fired, and the edge runtime killed the function at its roughly 50-second wall clock. The fix converts silence into a throw: a 12-second budget for the first byte, a 20-second budget for a mid-stream silence, applied to the fallback too, so a double stall returns 503 instead of hanging. It is silence-only, so a slow but steady answer is never truncated.

Q: Did you fix everything you found?
A: No. Seven worms, all seven fixed and verified live — and one open item that was never a worm: the agent occasionally refuses a legitimate question. We asked “Does Axoworks offer on-premise LLM work for AEC firms?” and it was refused once and answered another time. That is a wording-consistency problem, not a safety win, and we have not fixed it. We are saying so on the page rather than in a footnote.

Q: What was the single most transferable finding?
A: That row-level security protects a table, not a function. Our transcript tables were locked: a raw SELECT with the public publishable key returned 0 rows. The RPC path over the same tables was SECURITY DEFINER and the anon role held EXECUTE, so the same key that every browser bundle ships could read and write any session’s transcript. The proof was an error code: the write probe returned constraint error 23502 rather than permission denied 42501, which means the function executed for that key. One error says you may not. The other says you may, but not like that.

Q: Is it safe to publish this?
A: Every finding here is fixed except the one stated above, and the transcript findings all require a session id before they are reachable at all. Those ids are client-generated with roughly 67 bits of entropy, which is not brute-forceable, and we checked the public log repository and found no session ids published in it. We are still not going to publish the mechanisms in enough detail to be a recipe. An audit is reconnaissance if you hand it to an attacker.


What is still open

One item, stated plainly, plus the scope note that keeps the rest of this page honest.

The over-refusal problem is not fixed. Our agent occasionally declines a question it should answer. The example we have on record is “Does Axoworks offer on-premise LLM work for AEC firms?” — refused once, answered another time, same service, same voice, different wording on the way in. This is a consistency problem in how the question is phrased, not a safety feature, and it is exactly the class of defect Part 2 called a sales metric: a machine that asks us a question we advertise an answer to and gets a brush-off does not experience a personality, it experiences a broken tool. We are chasing the wording layer. We have not caught it. We are not going to claim we fixed it.

Scope note on the transcript findings, because it changes their severity and it would be dishonest to leave out. Every transcript-related worm in sections 04–09 requires knowing a session id before it is reachable. Those ids are client-generated with roughly 67 bits of entropy, which is not brute-forceable, and we checked the public log repository and found no session ids published in it. That does not make an open data plane acceptable — it is fixed, and it should have been fixed before it shipped — but a firm reading this should calibrate: these were structural failures waiting for a leak, not live disclosures.

And the standing item from Part 2 that has not moved: per-address limits bound one attacker, not an attack. The durable control is still a global spend breaker with an alert below the threshold. It is on the list, and this article is not going to imply otherwise.


15 / The bottom line

Part 1 built the door. Part 2 found out who was holding it. Part 3 taught the storefront to talk — and the conversation is what opened the floor.

The pattern across three articles is now boring enough to be a rule. Every one of the seven worms was in the plumbing around the intelligence, not in the intelligence. The 215+ probes never found the model. The transcript store did not survive them. Neither did the ownership model, the signing key, or the second writer of every user turn. And the single most instructive failure was not a defect at all — it was our own correct fix, quietly gating a capability we had forgotten to count.

What A2A actually bought us is worth stating without the war stories. A visiting agent can now arrive with a half-formed question, get an answer, ask a follow-up, cancel a task it no longer needs, and read a task status an hour later. It can also be told no in a way it can parse. In an industry where the deliverable is stamped, the ability to converse — and specifically the ability to refuse inside a conversation rather than returning the nearest plausible number — is the product. That is what “the chef can discuss the dish” means, and it is not a metaphor about personality. It is a statement about scope, grounding, and being asked twice.

The bill for that ability is the seven sections above. A conversational surface is a data plane with an owner, a life cycle, and multiple writers. Build it knowing that, and the audit is a week. Build it as an add-on, and the audit is the article you are reading.

One more thing worth saying, because it is the honest version of the last six months. One of the fixes in this piece was not ours: the image-URL hardening was Gemini on Antigravity’s work, credited above, and it was better engineering than a check bolted onto the sanitiser would have been. The three-LLM day was not a triumph either. It was a provider going down in a way our code could not see, and every visitor paid for it. What we have is not a hardened stack. It is a stack that has been attacked repeatedly and has written down what it found — which is a different and more durable claim.

“The Concierge is prepped for tomorrow’s AI. The industry may not be ready — but we are, as usual.”
— the owner, posted on LinkedIn, 9/13/26, on Google’s Agent2Agent protocol

The chef can discuss the dish. Which means somebody has to run the kitchen.

Part 1 — Zero-Click Marketing: How Axoworks Built an MCP Connector for the Agents Who Never Click — the case for building the door: why a small AEC practice replaced its brochure with an endpoint, and what the click stopped measuring.

Part 2 — The Front Door Is an API — What Happened When We Attacked Our Own MCP Storefront — the adversarial pass on the MCP endpoint: the client-trusted counter, the write path gated at one door and open at the other, the CSS “redactions,” the daily budget that did not exist, and the production outage our own hardening caused.

This article is an account of AxoWorks’ own A2A implementation and the audit that followed it. The graded technical reports, the per-file change logs, and the re-runnable verification scripts remain internal. Every claim above was verified against the live production endpoint before publication. Where a fix came from someone else, we have named them. Where a problem is unfixed, we have said so in the section called “What is still open” rather than implying a clean sweep. If something here is wrong, say so; corrections land on this page.


The rest of this line of work

Part 3 is the third and final panel of one project: an agentic storefront, attacked twice, that now holds a conversation. These are the pieces it leans on:

Sources:

First-party (AxoWorks-published and internal; figures self-reported): the A2A/MCP adversarial audit and the graded stress-test scoreboards (94 / 80 / 41 probes, 7.1 → 6.8 → 8.7) on axoworks.com and its agent endpoints, September 2026 · the transcript, ownership, and writer audit underlying Worms 01–06 (600 most recent message rows across 182 sessions) · the retrieval index audit behind section 11 (149-row corpus, 58 chunk rows across 11 documents) · the image-healer differential run report (72/72 identical) · the streaming-silence incident record for the DeepSeek US outage (section 12) · Part 1: Zero-Click Marketing · Part 2: The Front Door Is an API.

Third-party research: Agent2Agent (A2A) protocol, Google — the agent card, message/send, tasks/get and tasks/cancel semantics this implementation follows · Model Context Protocol (Anthropic, November 2024; OpenAI, March 2025; Google, April 2025) · PostgreSQL error codes 23502 (constraint error) and 42501 (permission denied) · OWASP guidance on access control at function boundaries, secret management in client bundles, and indirect prompt injection.

Attribution: both quoted lines are the owner’s own words, posted on LinkedIn on 9/13/26, as a comment on Google’s Agent2Agent protocol. The image-URL hardening in section 13 was implemented by Gemini on Antigravity; the differential test that proved it behaviour-preserving is what allows us to publish it under our own name.

Cross-checked against published first-party surfaces: the Agent2Agent block in https://axoworks.com/llms.txt, which advertises the endpoint, the agent card and its alias and schema, protocol version 0.1.0, the method set, the one-hour task TTL, owner-only task readability, and the structured-invocation requirement on book_consultation; and the Part 1 and Part 2 canon pages listed above.