integrations.yml has declared two channels since the REST version:
channels:
rest:
enabled: true
inspector:
enabled: true
The agent answers on both, and the tool result is identical on both. What differs is what a reader can actually take in — and that difference is settled in the skill, not in the channel.
The case that shows it
list_open_tickets returns structured rows:
{
"ok": true,
"count": 2,
"tickets": [
{
"id": "9001",
"subject": "Invoice 4471 shows the wrong VAT rate",
"stage": "waiting_on_us",
"created": "2026-08-11"
},
{
"id": "9002",
"subject": "Add a second admin to the account",
"stage": "waiting_on_contact",
"created": "2026-08-19"
}
]
}
In the Inspector that renders as a tidy little table and looks finished. Pushed
through the REST channel it arrives as one run of text. Read aloud by a voice
channel it would be unusable: nobody can hold waiting_on_contact and a ticket
id in their head while the next row starts.
The instruction that fixes it
check_tickets does not say “return the ticket list”. It says:
- Success with tickets: read back each subject and its stage in plain language.
`waiting_on_us` means Meridian owes them a reply; `waiting_on_contact` means
the ticket is waiting on the customer.
Three decisions are packed into that, and each one is about the listener rather than the data:
- Read back the subject, not the id.
9001means nothing to the caller. - Translate the stage.
waiting_on_usis a database value; “we owe you a reply” is an answer. - Plain language, not a table. The narrowest channel the agent serves decides the shape, and a table does not survive being spoken.
The result is a sentence that works everywhere:
bot Two. Invoice 4471 shows the wrong VAT rate, waiting on us. And a request to
add a second admin, waiting on you.
Shape once, in the skill
The tempting alternative is a per-channel template: a table for the Inspector, a sentence for voice, something else for REST. Resist it for as long as you can. Every channel you add multiplies the surfaces where the wording can drift, and the version a reader complains about is always the one nobody remembered to update.
Writing the constraint into the instructions once means the model produces an answer shaped for the narrowest consumer, and the wider ones display it fine. You lose the tidy table in the Inspector. You gain one description of what a good answer sounds like.
The connection to the transport swap
This is the same property as chapter 4’s constraint, seen from another angle.
Formatting was decided against the result shape — the tool returns tickets
with a subject and a stage — and not against the transport that carried the
result. So when the transport changed, the formatting instruction did not have to,
because nothing it depended on moved. subject and stage are still there,
still spelled the same way, still meaning the same thing.
Three things in this project turned out to be portable across the swap:
instruction prose, tool constraints, and output shaping. All three were written
against the interface. The one thing that was not portable — the tool reading
project.contact_id out of memory — was written against the implementation.
That is the whole lesson, and it is not really about MCP:
Write against the interface and the transport is an implementation detail. Write against the implementation and it is not.
MCP is just the change that made the difference visible.
Where to go next
- Run
make mcp-proveon your own fork and break it three ways, as in chapter 1. A proof you have not seen fail is not one yet. - Point
mcp_servers:at a real server. HubSpot’s own is two lines of YAML away, and the three skills do not change for it either. - Audit your own tools for chapter 3’s rule: which of them depend on a value the caller must not be able to influence? Those stay local.
