Wrapping a SaaS API as an Agent Tool
There is a moment in every agent build where someone says, "the API is just there, why don't we let the agent call it directly?" Six months later that same someone is staring at an audit log explaining why the agent created twelve duplicate Salesforce opportunities at 3 a.m. The right answer is that a SaaS API is not an agent tool. It is the raw material from which an agent tool is built. Wrapping is the work of converting a vendor's REST endpoint — designed for engineers writing imperative code — into a tool whose name, description, parameter schema, and return contract are designed for a language model making decisions under uncertainty. This lesson walks through exactly that wrapping work using three real APIs you will likely meet in production: Salesforce, HubSpot, and Linear. Four fields, two diff examples, one full implementation, and the patterns that separate a wrapped tool a senior agent builder ships from a "we just bound the SDK" mistake.
The Four Fields That Make a Tool
Every production-grade agent tool definition has the same four fields, regardless of platform — n8n, Relevance AI, Lindy, LangGraph, the OpenAI Assistants API, or an MCP server. The four are:
- Name — the identifier the model uses to refer to the tool. Verb-led, snake_case, descriptive enough that a junior engineer reading it understands what it does without reading the description.
- Description — the natural-language instructions the model actually reads at decision time. This is the highest-leverage field by an order of magnitude. Lesson 1 covered the shape; this lesson is where you write one.
- Parameter schema — the typed contract describing what arguments the tool accepts. Usually JSON Schema in 2026. Determines what the model can pass; constrains hallucination.
- Return contract — what the tool returns on success, what it returns on no-match, what it returns on error. The agent reasons over the return; if the contract is loose the agent's behavior is unreliable.
Most failure modes in production agent tooling reduce to one of these four fields being underspecified. The discipline is to write all four at the same time, deliberately, before any code goes live. The wrapping work below shows that discipline applied to three named SaaS APIs.
Wrapping a Salesforce Account Lookup
We start with a job common in customer-support agents: look up a customer's account by name, email, or phone, and return the canonical record with the data the agent needs for downstream reasoning.
The naive wrap: bind the SDK
The naive wrap binds the Salesforce REST API directly. Pseudocode:
tool: salesforce_query
description: "Executes a SOQL query against Salesforce."
parameters: { query: string }
returns: raw Salesforce response (nested JSON, includes URLs)
Why this is bad: the LLM writes SOQL. SOQL is a specific dialect with named-relationship syntax (Account.Owner.Email) and inconsistent quoting rules. The model gets it wrong roughly 18% of the time on simple queries and over 40% on queries with joins. The return is nested JSON with attribute URLs and metadata the agent doesn't need. The tool has no semantic boundary — anyone with the tool can read any object.
The wrapped version
The wrapped version delivers the same capability but the four fields are designed for the LLM:
Name: find_salesforce_account
Description:
Finds a Salesforce Account by name, email, or phone number. Searches across Account.Name, Account.Website, the primary Contact's email, and all phone fields on the Account and primary Contact. Returns the canonical Account record with key fields flattened into one object.
Use this tool whenever the user names or describes a specific customer that you need to look up. Examples: "What's the status of Acme Corp?", "Find the account for [email protected]", "Open the case for 415-555-0100".
Do NOT use this tool when the user asks general capability questions or when you already have the Account ID in context from a previous tool call — in that case, use get_salesforce_account_by_id instead.
Returns: { account_id, name, website, owner_name, owner_email, tier, primary_contact_name, primary_contact_email, recent_activity_summary: string, last_modified_at }. Returns null if no account matches; in that case ask the user for clarification rather than guessing. Returns the first match if multiple accounts match; the description includes how many additional matches existed so you can ask which one the user meant.
Parameter schema:
{
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Name, email, phone, or website. The tool resolves any of these against the appropriate Salesforce fields."
},
"match_strategy": {
"type": "string",
"enum": ["exact", "starts_with", "fuzzy"],
"description": "exact for verified identifiers (full email, full account ID); starts_with for partial names; fuzzy when the user's phrasing is approximate. Defaults to starts_with.",
"default": "starts_with"
}
},
"required": ["query"]
}
Return contract:
{
"account_id": "001x0000000abc",
"name": "Acme Corporation",
"website": "acme.com",
"owner_name": "Sara Chen",
"owner_email": "[email protected]",
"tier": "Gold",
"primary_contact_name": "John Doe",
"primary_contact_email": "[email protected]",
"recent_activity_summary": "Last contacted 2026-04-12 by Sara Chen. 1 open case. 1 closed-won opportunity in last 30 days.",
"last_modified_at": "2026-04-12T14:33:00Z",
"additional_matches": 0
}
On no-match: returns null. On error: returns { "error": "salesforce_unreachable", "retry_after_seconds": 30 } or similar typed error code the agent's loop can branch on without parsing strings.
What changed and why it matters
- The LLM never writes SOQL. The wrapping code constructs the query from the typed parameters. Query reliability becomes 100% because the wrapper, not the model, is responsible for syntax.
- The return is flat. Owner name and email are on the same object as the account. The agent does not need a second tool call to resolve
Owner.Email. - The recent_activity_summary is pre-summarized. Rather than returning a list of activity objects the agent has to summarize, the wrapper does it. This costs the wrapper an extra Salesforce call but saves the agent two reasoning turns.
- additional_matches is exposed. If the search returned three accounts, the wrapper picks the most-recent and returns
additional_matches: 2, so the description can say "I found 3 matching accounts; the most recent is Acme Corporation — should I look at the others?" - Typed errors. The agent can react differently to "Salesforce is down" versus "Salesforce returned an empty result" versus "the user's query was malformed." Each is a distinct branch in the agent loop.
The wrap is roughly 80 lines of code in Python or TypeScript and pays off in every subsequent agent built against the same Salesforce instance. The MCP server pattern (Lesson 3.3) is exactly this wrap, packaged as a reusable server.
Wrapping a HubSpot Deal Update
Second example: update the stage of an existing HubSpot deal. This is a write tool, which raises stakes. Idempotency, scope, and the return contract matter more.
Naive wrap problem
The naive wrap is HubSpot's PATCH /crm/v3/objects/deals/{dealId} endpoint bound directly. The agent picks a deal ID from prior context, picks a stage name from prior context, and PATCHes. Failure modes that bite teams:
- The agent uses a stage label ("Proposal Sent") when HubSpot's API requires the internal stage ID ("appointmentscheduled" or similar). The PATCH succeeds with an unrelated stage. The CRM is now wrong.
- The agent re-runs the same update after a retry, and the deal jumps stages twice (Lesson 3 covers this in depth).
- The agent has write access to all HubSpot fields, not just the stage field. A prompt-injection (Lesson 3.7) flips the deal owner to a fictional user.
The wrapped version
Name: update_hubspot_deal_stage
Description:
Updates the pipeline stage of an existing HubSpot deal. Only this field — the stage — is modifiable through this tool. Use this when the customer or internal user has confirmed a deal should move to a specific named stage.
Allowed stage names (use the human label; the tool translates to HubSpot's internal stage ID): "Discovery", "Demo Scheduled", "Proposal Sent", "Negotiation", "Closed Won", "Closed Lost". Any other value will return a typed error.
Use this tool only after confirming the deal ID with find_hubspot_deal. Do NOT use this tool to move a deal to "Closed Won" without explicit human approval — in that case route to the approval workflow instead.
The tool is idempotent on the (deal_id, target_stage, idempotency_key) tuple — calling it twice with the same arguments produces the same result without double-moving the deal.
Returns: { success: bool, deal_id, previous_stage, new_stage, changed_at, change_id }. On a stage that isn't allowed: { error: "stage_not_allowed", allowed_stages: [...] }. On a deal_id that doesn't exist: { error: "deal_not_found", deal_id }.
Parameter schema:
{
"type": "object",
"properties": {
"deal_id": { "type": "string", "description": "HubSpot deal ID, typically 8-12 digits." },
"target_stage": {
"type": "string",
"enum": ["Discovery", "Demo Scheduled", "Proposal Sent", "Negotiation", "Closed Won", "Closed Lost"],
"description": "Human stage label. The tool translates to HubSpot's internal stage ID."
},
"reason": { "type": "string", "description": "Brief reason for the stage change. Written to the change_id audit row." },
"idempotency_key": { "type": "string", "description": "Unique key for this logical operation. Re-running the tool with the same key returns the prior result." }
},
"required": ["deal_id", "target_stage", "reason", "idempotency_key"]
}
Return contract:
{
"success": true,
"deal_id": "12345678",
"previous_stage": "Demo Scheduled",
"new_stage": "Proposal Sent",
"changed_at": "2026-05-16T17:22:00Z",
"change_id": "chg_a1b2c3d4",
"audit_url": "https://audit.example.com/changes/chg_a1b2c3d4"
}
What this wrap enforces
- Only one field is modifiable. The wrapper exposes only the stage parameter, not the deal owner, value, or close date. A prompt injection cannot pivot to a different field through this tool.
- Stages are enumerated. The model picks from a closed list. There is no path to "the agent typed something weird and HubSpot accepted it."
- "Closed Won" requires human approval. The description directs the agent to route to approval rather than self-acting. This is policy encoded in the tool, not in the prompt.
- Idempotency key is required. Re-running with the same key returns the prior result, not a duplicate move. This is the "Run Twice" defense from Lesson 3 of this chapter, made native to the tool's contract.
- Audit row and URL on return. The agent's response can include a link to the audit row, which is what stakeholders ask for when they want to verify what the agent did.
Wrapping a Linear Issue Create
Third example: create a Linear issue from the agent's conversation with a customer. This is the most common "write" tool in support agents in 2026 — a customer-facing conversation turns into a tracked engineering issue.
Name: create_linear_issue
Description:
Creates a Linear issue in the specified team. Use this when a customer or internal user has reported a bug or feature request that engineering needs to track. Always confirm the issue's required fields with the user before creating, especially the team and priority.
Required: team key (slug or name; the tool resolves either), title, description. Optional: priority (Urgent/High/Medium/Low/No priority), label IDs.
Use this tool only after the customer has confirmed the issue summary back to you. Do NOT use this tool for general questions or speculative "we might want this" requests; in those cases either log a note via log_customer_note or ask the user to confirm.
Idempotent on (team_id, title_hash, idempotency_key). Re-running with the same key returns the previously created issue rather than creating a duplicate.
Returns: { success, issue_id, issue_url, identifier (e.g. ENG-1234), title, state }. On team not found: { error: "team_not_found", available_teams: [...] }. On Linear unreachable: { error: "linear_unreachable", retry_after_seconds }.
Parameter schema:
{
"type": "object",
"properties": {
"team": { "type": "string", "description": "Team slug (e.g. 'eng') or full name. The tool resolves either." },
"title": { "type": "string", "description": "One-line issue title. Max 200 chars." },
"description": { "type": "string", "description": "Markdown-formatted body. Include reproduction steps when applicable." },
"priority": {
"type": "string",
"enum": ["Urgent", "High", "Medium", "Low", "No priority"],
"default": "No priority"
},
"label_ids": {
"type": "array",
"items": { "type": "string" },
"description": "Optional list of Linear label IDs."
},
"idempotency_key": { "type": "string", "description": "Unique per logical issue-create operation." }
},
"required": ["team", "title", "description", "idempotency_key"]
}
Return contract:
{
"success": true,
"issue_id": "uuid-here",
"issue_url": "https://linear.app/team/issue/ENG-1234",
"identifier": "ENG-1234",
"title": "Customer cannot upload PDF attachments larger than 25MB",
"state": "Backlog"
}
What this wrap enforces
- Required confirmation in the description. The natural-language description tells the agent to confirm before creating. This is policy in the tool, not the prompt.
- Team resolution. The agent can pass either a slug or a name; the wrapper resolves. If neither matches, the error returns the available teams so the agent can pick.
- Priority constrained. Linear's API accepts any string; the wrapper constrains to the five supported labels. No "P0" or "critical" strings making it through.
- Idempotent. Same defense as the HubSpot example — re-running with the same key returns the existing issue rather than creating a duplicate.
- Return includes the human identifier. The agent's response can say "Created issue ENG-1234" with a clickable URL, which is what the customer expects to see.
Patterns Across the Three Wraps
Salesforce, HubSpot, and Linear are three different products with three different APIs. The wraps share patterns that survive across vendors. These are the patterns to internalize.
Pattern one: the wrapper does the work the LLM is bad at
Constructing query syntax (SOQL, GraphQL, complex REST filter strings) is the work LLMs are inconsistent at. The wrapper does it. The LLM passes typed parameters; the wrapper turns them into vendor calls. This pattern is responsible for most of the accuracy gains in wrapped tools.
Pattern two: typed errors, not exception strings
Every wrap returns typed error codes (salesforce_unreachable, stage_not_allowed, deal_not_found, team_not_found) the agent can branch on. The agent loop has explicit handling per error type. Exception strings parsed by the model are a foot-cannon.
Pattern three: pre-summarized auxiliary data
The Salesforce wrap returns recent_activity_summary as a sentence rather than a raw activity list. The HubSpot wrap returns previous_stage as a label rather than an ID. The wrapper pre-formats data the agent would otherwise need to format itself.
Pattern four: idempotency by default on writes
All write tools take an idempotency_key as a required parameter. The wrapper records (key, result) and returns the prior result on repeat. The agent does not have to know it's a retry; the tool handles it. Lesson 3 of this chapter goes deep on this pattern.
Pattern five: enumerate where the vendor allows free strings
HubSpot stages, Linear priorities, Salesforce account tiers — wherever the vendor's API takes a freeform string, the wrap converts it to an enumeration. This is the largest source of "the agent set a wrong value" bugs we see in audits.
Pattern six: the description tells the agent about the next tool
The Linear wrap's description points to log_customer_note for speculative requests. The HubSpot wrap points to find_hubspot_deal for the prerequisite lookup. The wrap is part of a tool surface, not an island. Descriptions that mention sibling tools by name produce far more coherent agent behavior.
The Five-Step Wrapping Recipe
For any SaaS API endpoint you intend to expose to an agent, run this five-step recipe. Total time per tool: 60-90 minutes for the first one in a new vendor; 20-30 minutes per subsequent tool against the same vendor.
- Define the job in one sentence. Write it down. If you cannot, you are looking at an endpoint, not a job. Go back to Lesson 1.
- Pick the name. Verb-led, snake_case, distinct from any sibling tool in this agent's surface. Confirm a junior engineer can guess the purpose from the name alone.
- Write the description before the schema. Lead with the job, name when to use, name when NOT to use, name the sibling tools the agent should disambiguate against, preview the return shape, name the null and error contracts. Roughly 80-200 tokens.
- Design the parameter schema. Required parameters are the irreducible inputs. Optional parameters have explicit conditions. Use enums wherever the vendor's API accepts strings that should be constrained. Add an idempotency_key on any tool that writes.
- Write the return contract. Flat shape. Pre-summarized strings where helpful. Typed errors with retry hints. Audit URLs for write operations.
The recipe is platform-agnostic. It works for MCP tools, OpenAI Assistants, LangGraph nodes, n8n custom AI tool nodes, Relevance AI tools, and Lindy actions. The platform changes the framing; the four fields and the patterns do not.
Anti-Patterns Specific to Wrapping
Three anti-patterns show up in 90% of "we wrapped the SDK" code reviews.
Anti-pattern: passthrough wraps that re-expose the vendor's shape
A "wrap" that simply forwards the vendor's response object to the agent. The vendor returns nested URLs, attribute objects, version metadata — none of which the agent uses. The agent now spends tokens parsing fields it will ignore. The fix: return a flat, agent-shaped object. The wrapper's job is also format translation, not just API access.
Anti-pattern: the "advanced parameters" escape hatch
The wrap has well-designed required parameters and then an advanced or extra_filters field that takes arbitrary JSON. This re-introduces every problem the wrap was meant to prevent. The agent now writes vendor-specific filter syntax. The fix: if you need a capability, add a typed parameter for it. If you don't need it, don't expose it.
Anti-pattern: writes without audit returns
A write tool that returns { success: true } and nothing else. The agent cannot tell the customer what happened, the audit log has no reference to point at, and reverse engineering the action later requires reading the vendor's change log. The fix: every write returns a stable change_id and ideally an audit_url.
The Wrap as a Living Contract
A wrapped tool is not a one-time write. It is a living contract between the agent and the vendor's API. Vendors deprecate endpoints, add fields, change behavior. The wrap is the buffer that prevents those changes from breaking every agent in your organization.
The wrap's tests are owned by the agent platform team. The wrap's description is reviewed when the vendor's behavior changes meaningfully. The wrap's eval cases catch regressions when the vendor or the model shifts.
This is part of why MCP servers (Lesson 3.3) became dominant in 2026: the MCP server is the institutional home for wrapped tools. One team writes the Salesforce MCP server; ten agent teams consume it. The wrap is shared infrastructure, not per-agent reinvention.
How This Hands Off to Lessons 3 and 4
Two of the patterns in this lesson — idempotency keys and field-scoped writes — get full lessons in this chapter. Lesson 3 covers idempotency in depth: the "Run Twice" problem where an agent retried and sent 47 emails, the design patterns (idempotency keys, dedup tables, write-once contracts), and the wrapper code that prevents double-charging. Lesson 4 covers permission scoping: OAuth scopes like crm.read versus crm.full_access, why stripping write access from tools that don't need it is the single most underused agent security control, and a worked scope-review walkthrough.
Treat this lesson as the four-field foundation. Treat the next two as the safety properties layered on top.
A SaaS API is the raw material. The wrap is the tool. The agent never sees the API; it sees the wrap. Make the wrap the surface you would trust at 2 a.m.
Key Takeaways
- Every production agent tool definition has four fields: name, description, parameter schema, and return contract. Most failure modes in production tooling reduce to one of the four being underspecified. The discipline is writing all four deliberately before any code goes live.
- Name: verb-led, snake_case (
find_salesforce_account,update_hubspot_deal_stage,create_linear_issue). Description: the highest-leverage field; 80-200 tokens leading with the job, naming when to use and when NOT to use, previewing the return shape and null/error contracts. - Parameter schema: required params are irreducible inputs; optional params have explicit conditions; use enums wherever the vendor accepts unconstrained strings; require an idempotency_key on every write. Return contract: flat shape, pre-summarized strings, typed errors with retry hints, audit URLs for writes.
- The wrapper does the work the LLM is bad at. Constructing SOQL, GraphQL, complex filter strings — that's the wrapper's job. The LLM passes typed parameters. SOQL written by the model fails 18-40%+ of the time; SOQL constructed by the wrapper is 100%.
- Salesforce wrap key moves: flatten the return so Owner.Email is on the same object as the account; pre-summarize recent activity into a sentence; expose additional_matches so the agent can ask which of several to use; return null when nothing matches (don't guess).
- HubSpot wrap key moves: only one field modifiable through this tool (the stage); enumerate the allowed stages instead of accepting freeform strings; require an idempotency_key; route "Closed Won" to human approval rather than self-acting; return a change_id and audit_url.
- Linear wrap key moves: require explicit confirmation per the description; let the agent pass either team slug or name (wrapper resolves); constrain priorities to the five labels; return the human identifier (ENG-1234) so the agent can tell the customer.
- Six universal wrap patterns: (1) wrapper does the work the LLM is bad at, (2) typed errors not exception strings, (3) pre-summarized auxiliary data, (4) idempotency on writes by default, (5) enumerate where the vendor allows free strings, (6) descriptions mention sibling tools by name.
- Three anti-patterns to audit out: passthrough wraps that re-expose the vendor's nested response; "advanced parameters" escape hatches that re-introduce every problem the wrap was meant to prevent; write tools that return only
{success: true}without an audit reference. - The 5-step wrapping recipe: define the job in one sentence; pick the verb-led name; write the description before the schema; design the typed parameter schema with enums and idempotency_key; write the return contract with flat shape and typed errors. Typical timing: 60-90 minutes for the first tool against a new vendor, 20-30 minutes per subsequent tool.
- A wrap is a living contract, not a one-time write. Vendors deprecate endpoints; agents shift; the wrap is the buffer. MCP servers became dominant in 2026 because they give wrapped tools an institutional home — one team writes the Salesforce MCP server, ten agent teams consume it.
Skill.re