Guides
HTTP APIs as tools
Connect any REST API without an MCP server. Declare its endpoints, or import them from an OpenAPI file, and each becomes a tool the office calls with a key the agent never sees.
Many services have a plain HTTP API but no MCP server: your billing system, a CRM, an internal inventory service. An HTTP API connection turns the endpoints you choose into agent tools. You declare them in connection.yaml, or pick them from an OpenAPI file. The office adds the API key to each request on its side, so the agent's sandbox never holds it.
Each endpoint is a normal connection tool. Grants, pin, args and approve work exactly as for any other service, and every call is ledgered as connection.call.
How it works#
- The agent calls
billing__list_invoiceswith arguments, like any connection tool. - The office checks the grant, sets pinned arguments and refuses values outside
args. If the tool is inapprove, it files a request on Needs you first. - It builds the request on the connection's base URL and adds the credential from the secret store. Then it sends the request through the office's network guard, which refuses private addresses.
- The agent gets the status and the body back. JSON is pretty-printed, long responses are cut, and any echo of the key is replaced by
[redacted].
1. Declare the endpoints#
Connections → Add connection → HTTP API prefills a form: the base URL, where the key goes, and the endpoints as YAML. It opens a pull request adding connections/<name>/connection.yaml:
title: Acme Billing
description: Invoices and refunds.
transport: http-api
url: https://billing.example.com/v2 # the base URL; every endpoint path goes under it
identity: office # office (one key) or agent (a key per agent)
http_api:
auth:
type: header # bearer | header | query | basic | none
name: X-Api-Key # the header or query parameter (header and query only)
headers: # optional fixed headers, never secrets
Accept: application/json
max_response_chars: 20000 # default 20,000; at most 200,000
timeout_seconds: 30 # default 30; at most 120
endpoints:
list_invoices: # the tool name: lowercase and underscores
method: GET
path: /invoices
description: List invoices, newest first.
params:
status: { in: query, enum: [open, paid, void] }
customer_id: { in: query }
limit: { in: query, type: integer, description: At most 100 }
get_invoice:
method: GET
path: /invoices/{invoice_id} # {placeholders} are path params
description: One invoice with its lines.
params:
invoice_id: { in: path }
refund_invoice:
method: POST
path: /invoices/{invoice_id}/refunds
description: Refund all or part of an invoice.
params:
invoice_id: { in: path }
amount_cents: { in: body, type: integer, required: true }
reason: { in: body }
| Param field | Meaning |
|---|---|
in | path (a {placeholder}), query (default) or body (a field of the JSON body) |
type | string (default), integer, number, boolean, array (with items) or object |
required | The agent must pass it. Path params are always required. |
enum | The only values allowed |
description | Shown to the agent in the tool's schema |
name | The name on the wire, when it isn't a plain identifier (page[size], X-Request-Id) |
as_body | For one in: body param of type object or array: its value is the whole JSON body |
The office refuses arguments the endpoint doesn't declare, wrong types and values outside enum. Path values are URL-encoded, and . or .. are refused, so a request can't leave the base URL. GET endpoints are marked read-only in the tool list and DELETE endpoints destructive.
2. Or import from an OpenAPI file#
Put the API's OpenAPI 3 file (JSON or YAML, up to 2 MB) next to connection.yaml in the same pull request, and name the operations to keep:
http_api:
auth: { type: bearer }
openapi:
file: openapi.yaml # connections/<name>/openapi.yaml
operations: # operationIds, or "METHOD /path"
- listInvoices
- getInvoice
- POST /invoices/{id}/refunds
endpoints: # optional: these win over imported ones with the same name
get_invoice:
method: GET
path: /invoices/{id}
description: One invoice. Amounts are in cents.
params:
id: { in: path }
Only the operations you list become tools. A whole public API never becomes callable by accident. The import reads path and query parameters (including shared ones and $refs), and turns a JSON request body's top-level properties into in: body params. readOnly properties are skipped. Tool names come from the operationId in snake case (listInvoices → list_invoices), or from the method and path. An operation that needs a header or cookie parameter, or has a body that isn't JSON, is not imported. The policy check says why. The base URL is always the connection's url, never the file's servers.
3. Store the credential#
Once merged, open the connection and paste the credential on its Overview tab:
auth.type | Secret | Sent as |
|---|---|---|
bearer | API token | Authorization: Bearer <token> |
header | API key | <name>: <key> |
query | API key | ?<name>=<key> (left out of what the agent sees) |
basic | Username and password | Authorization: Basic … |
none | nothing | nothing |
With identity: agent each agent gets its own key: grant the agent first, then paste its key. You can also store keys from the office host with officectl connections secret <name> [--agent <slug>] --key token (or --key username / --key password). An optional expiry date gets the same warnings as other tokens.
4. Grant the tools#
capabilities:
- connections:
billing:
tools: [list_invoices, get_invoice, refund_invoice]
pin: { customer_id: cus_acme_eu } # the office sets it; the agent can't change it
args: { status: [open, paid] } # only these values
approve: [refund_invoice] # a person approves each refund first
Responses#
The agent gets the method and path, the HTTP status and content type, then the body:
- JSON is pretty-printed.
- Text (
text/*, XML, CSV) is shown as it is. - Binary bodies are described, not shown:
(binary image/png, 48213 bytes; not shown). - Everything is cut at
max_response_chars, with a note asking the agent to request less, such as a smaller page. The office reads at most 2 MB of any response. - Status 400 and above come back as tool errors, so the agent sees that the call failed.
- Redirects are not followed. The agent sees the
Location, so a credential is never sent to a second host.
Internal APIs#
By default the office reaches public addresses only. Requests to loopback, private (10/8, 172.16/12, 192.168/16, IPv6 ULA), link-local and other reserved addresses are refused. That includes host names that resolve to them, and IPv6 forms that embed a private IPv4 address. The check runs when the connection opens, so DNS rebinding can't slip past it. The agent gets Blocked: … is on a private network address.
To connect an internal API, set allow_private: true. Plain http:// URLs are then allowed too:
url: http://inventory.acme.internal/api
http_api:
auth: { type: bearer }
allow_private: true
A pull request that turns allow_private on needs an access approver, as for any connection, and it must also be merged by an admin or the owner. The pull request page says so. Allowing it points the office at its own network, so keep these connections narrow: few endpoints, read-only where you can, and approve on anything that changes data.
Security notes#
- The credential stays in the encrypted secret store and is added by the office for each request. It never reaches the agent's sandbox, and it is redacted from responses, the ledger and the logs.
- No credentials in the policy:
urlcan't carry a user or password, andheaderscan't setAuthorization,CookieorHost. - Only the endpoints you declare exist. The agent can't call other paths, methods or hosts.
- Response text comes from outside the office. The agent is told to treat it as data, not instructions.