The first tool takes an afternoon. The sixth takes a sprint, and the agent never changed. Here is what actually grows when you wire an agent into real systems.
The first one takes an afternoon. Slack, one endpoint, one token, a function that posts a message and hands back a timestamp. Done before lunch. The sixth takes a sprint, and by then not one line of your agent loop has changed. AI tool integration is the work that gets estimated once and paid for every week after, because the thing that grows is not the number of tools. It is the number of independent surfaces you have to keep alive.
Day 14 defined a single tool: name, description, schema, executor, result, error. Clean. This is what happens when you need five of them, wired to real systems, running for real users, at 3am when a provider rotates a certificate.
One agent, one tool, one wiring. Add four more and the naive read is five afternoons of work. Wrong in both directions. Some of it was cheaper because you copied the HTTP client. Most of it was dearer, because each wiring drags six independent concerns behind it, none of them shared with the tool you wired last week.

Then someone ships a second agent. A nightly summarizer, an eval harness, a support bot that needs the CRM read the main agent already has. If each agent talks to tools directly you have agents times tools wirings. Three agents and five tools is fifteen. Times six concerns is ninety things that can rot quietly until a customer finds one.
The M times N framing is old and not specific to AI. It just arrives faster here, because an agent makes it very cheap to want another tool. Direct wiring costs M times N. A boundary that every agent speaks and every tool implements costs M plus N. At six agents and twenty tools that is one hundred and twenty versus twenty six, which is the difference between a sprint and a team.
Integrations do not compose because no two providers agree on anything below the word "API".
Slack's Web API is method style: you POST to https://slack.com/api/chat.postMessage and the operation lives in the path, not the verb. GitHub is resource oriented REST where the verb carries meaning. Notion rejects any request without a Notion-Version header. Some internal services are gRPC, some payment APIs still want form encoded bodies. Request construction is per provider and the differences are load bearing.
Five providers can mean five genuinely different mechanisms, each failing in its own way.
400 {"error": "invalid_grant"} when access was revoked or the refresh token rotated out from under you. Two concurrent calls that both see an expired token will both refresh, and with rotating refresh tokens one wins while the other permanently kills the credential.403 SignatureDoesNotMatch, which reads like a credential problem and is an NTP problem.Four questions separate these: where the secret lives, how long it lives, whose identity it carries, and what happens when it dies. No two of the five answer all four alike.
Timestamps are ISO 8601 here, epoch seconds there, epoch milliseconds next door, and Slack's ts looks like a float but is a message identifier you must never do arithmetic on. Missing values are null in one API and an absent key in the next, which matters the second you write row["assignee"]["name"].
Pagination is worse because it fails silently. Nothing crashes. You return page one and the agent reports that the customer has four orders. Four conventions are in wide use: a cursor in the body like Slack's response_metadata.next_cursor or Notion's next_cursor with has_more, a cursor in a header like GitHub's Link with rel="next", a page token like Google Calendar's nextPageToken, and plain offset and limit, where rows shift under you mid scan.
Authentication is the mechanism. Credentials are the operations problem, and separate work. Where is the secret stored, and is it encrypted with a key you actually rotate. Is it per tenant, per user, or one shared key pretending to be per user. Can you rotate without a deploy. What happens when a user disconnects an integration and forty queued jobs are holding a dead token. Who can read the secret, and is that read audited. None of it is tool logic.
"Add a tool" sounds like adding a file. Sometimes it is adding a container. A vendor SDK pulls native dependencies that pin your Python version. A browser tool needs headless Chromium, a gigabyte of memory and a process supervisor. An internal service sits inside a VPC, so the agent needs network placement, not a token.
Slack commonly returns 200 OK with {"ok": false, "error": "invalid_auth"} in the body. Branch on status_code alone and that call is logged as a success. GitHub returns 401 {"message": "Bad credentials"}. An OAuth refresh returns 400 invalid_grant. AWS returns 403 SignatureDoesNotMatch. Rate limits arrive as 429 with , or as GitHub's with .
No agent reasons well across five taxonomies. It will retry a permission error forever and give up on a rate limit that would have cleared in thirty seconds.
All of it collapses if you refuse to let provider shapes past a single boundary. The rule is unglamorous: the agent never sees a raw provider response.
from dataclasses import dataclass
from enum import Enum
from typing import Any, Protocol
class ErrorKind(str, Enum):
BAD_INPUT = "bad_input" # the agent sent something invalid
AUTH = "auth" # credential missing, expired or rejected
PERMISSION = "permission" # credential is valid, scope is not
NOT_FOUND = "not_found"
Four things do real work. Identity travels on the call instead of being read from ambient state. ToolError carries a kind the agent can branch on rather than a string it has to parse. retry_after is a number, not a header. And next_cursor is uniform, so pagination is one pattern regardless of the provider's convention.
The adapter base is a template method with four hooks, one per axis that actually differs.
import httpx
class HTTPTool:
"""Base for any tool that is an HTTP call into somebody else's service."""
name: str
provider: str # which credential to resolve
base_url: str
writes: bool = False
def __init__(self, credentials, client: httpx.Client):
self.credentials, self.client = credentials, client
# per provider hooks
def build_request(self, call: ToolCall) -> httpx.Request:
A Slack adapter then says exactly one interesting thing: a 200 is not a success.
class SlackPostMessage(HTTPTool):
name, provider, writes = "slack_post_message", "slack", True
base_url = "https://slack.com/api"
ERRORS = {"invalid_auth": ErrorKind.AUTH, "token_revoked": ErrorKind.AUTH,
"missing_scope": ErrorKind.PERMISSION,
"channel_not_found": ErrorKind.NOT_FOUND}
def
Every provider quirk now lives in one method on one class. The agent loop, the retry policy, the logging and the tracing get written once and never touched again when tool seven shows up.
Here is the most common production bug in multi-tenant agent systems.
import os
SLACK_TOKEN = os.environ["SLACK_TOKEN"] # do not do thisIt works perfectly in development, where there is one tenant and it is you. In production every tenant's agent posts into whatever workspace that token belongs to. There is no crash. There is user A's data arriving in user B's channel, discovered by user B.

The credential is a function of the call, not of the process.
import threading, time
class CredentialResolver:
"""store.get returns a Credential carrying .secret and .expires_at."""
def __init__(self, store, refreshers: dict):
self.store = store # encrypted and audited, not a dict
self.refreshers = refreshers # provider -> callable
self._locks: dict[tuple[str, str], threading.Lock] = {}
self._guard = threading.Lock()
The double check inside the lock stops a herd of simultaneous refreshes and lets another worker's refresh win. The sixty second skew refreshes before expiry rather than after a failed call. MissingCredential is explicit, because a missing credential must never quietly fall back to a default identity. And the secret never reaches a prompt, a log line or a trace attribute. Log the tenant, the provider and the credential ID.
Once every adapter classifies into the same six kinds, retry policy is a lookup table instead of a per provider argument.

| Kind | Typical provider signal | Retry | What the agent should do |
|---|---|---|---|
bad_input | 400 with a field error, invalid_arguments | No | Fix the arguments, try once more |
auth | 401, invalid_auth, invalid_grant | Once, after refresh | Stop, ask the user to reconnect |
permission | 403, |
The split that pays for itself immediately is auth versus permission. Both look credential shaped and need opposite handling. An expired token is fixable by your system without bothering anyone. A missing OAuth scope needs a human to reconsent, and retrying it a hundred times burns quota for nothing.
Error messages are prompts, so write them for the model. Tell the agent "invalid arguments" and it reformats the channel ID and tries again. Tell it "that channel does not exist in this workspace" and it lists channels.
With normalized errors, one retry wrapper covers every tool.
import random, time
def call_with_retry(tool: Tool, call: ToolCall, max_attempts: int = 3) -> ToolResult:
for attempt in range(1, max_attempts + 1):
result = tool.invoke(call)
if result.ok:
return result
retryable = result.error.kind in (ErrorKind.RATE_LIMIT, ErrorKind.UNAVAILABLE)
It honours Retry-After rather than guessing, because guessing shorter than the provider asked is how a soft limit becomes a hard ban. It jitters the backoff, because ten workers on a clean exponential schedule hit the provider in a synchronized wave forever. And it refuses to retry a write that cannot prove it is safe.
That last rule matters most. Retrying a read is free. If send_email times out after the provider already accepted the message, the naive retry sends it twice, and a timed out charge charges twice. The fix is an idempotency key bound to the logical action, generated once when the agent decides to act and reused on every attempt. Stripe takes it as an Idempotency-Key header and replays the original response. Many APIs do not, so keep your own durable record keyed on that value. An in-process dictionary does not count, because the retry that matters is the one after the pod restarted.
Every tool you integrate is a dependency that breaks on a schedule you do not control. Notion pins behaviour through a mandatory Notion-Version header. Stripe pins to the API version your account was created with and lets you override per request. Plenty of smaller vendors change a response field on a Tuesday and tell nobody.
Pin explicitly. Send the version header on every request, hardcoded in the adapter. An unpinned integration means the provider picks your deploy schedule.
Parse defensively. parse extracts the four fields you actually use into your own type. If a provider adds twelve fields, nothing happens. If it removes one you use, you get a clean error in one file instead of a KeyError buried in a prompt.
Contract test against the real thing. One nightly job per provider, hitting a sandbox with a real credential, asserting the shape your adapter expects. Highest value test in the tool layer, usually the last one written.
Then put the maintenance tax in the estimate. Twenty integrations, each shipping a breaking change roughly once a year, is a forced change every two or three weeks, forever, on somebody else's calendar.
auth and permission stay apart, and messages tell the model what to do nextRetry-After, carry jitter, and never fire blind on a writeEleven lines. Tick them all and tool twelve costs about what tool two did, which is the whole goal.
Everything above is a boundary layer you built yourself. Every team that gets past five tools builds one, they all build a slightly different one, and none of them compose. Your Slack adapter is worthless to the team down the hall, and their calendar tool is worthless to you.
The problem is not new and not specific to AI. Hardware answered with USB. Databases answered with ODBC. Editors had M editors times N languages and answered with LSP. Operating systems answered with POSIX. Every time the answer had the same shape: one contract at the boundary, many implementations behind it, wiring count falling from M times N to M plus N.
So what would that contract have to specify for AI agents? Based on everything that diverged above, at minimum:
That is a specification, not a library. Written out as a list, it stops looking like a wish and starts looking like something somebody has probably already built.
Somebody has. That is the next post.
403X-RateLimit-Remaining: 0| No |
| Stop, name the missing scope |
not_found | 404, channel_not_found | No | Stop or widen the search |
rate_limit | 429, Retry-After, X-RateLimit-Remaining: 0 | Yes, after the delay | Wait, then continue |
unavailable | 5xx, timeout, transport error | Yes, with backoff | Retry a few times, then report |