Secure calls
Two ways to call any API — provider actions and direct HTTP — and the proxy both share. Placeholders, scopes, refusals and self-repair. Specification v0.1.
Status v0.1
An agent calls an API without ever holding its credential. There are two ways to make the call, and both are secured the same way: the request reaches a proxy with the credential left as a placeholder, and the proxy substitutes the secret a human connected as the request leaves.
1. Two ways in
| Provider actions | Direct HTTP | |
|---|---|---|
| The agent writes | The outcome it wants, with typed inputs | The HTTP request, byte for byte |
| Command | providers <provider> <command…> | curl <url> [curl options] |
| API quirks and pagination | Handled by the action | Handled by the agent |
| When the API changes | Repaired by the network, for every caller | The agent updates its own request |
| Coverage | Actions that exist, or can be generated | Any endpoint of any provider |
| Credential | Placeholder in the action's code, added by the proxy | Placeholder in the request, added by the proxy |
Provider actions are for getting something done. Direct HTTP is for when an agent wants access of its own. Because both end at the same proxy, choosing between them is a question of convenience, never of security.
# the outcome
npx interagentic providers gmail messages list --query "is:unread"
# the request
npx interagentic curl -G https://gmail.googleapis.com/gmail/v1/users/me/messages \
-d q=is:unread \
-H "Authorization: Bearer {{OAUTH2_ACCESS_TOKEN}}"Sections 2 to 6 apply to both. Section 7 covers what is specific to provider actions.
2. Placeholders
A credential is written as its name in double braces, in upper case:
npx interagentic curl https://api.github.com/user/repos \
-H "Authorization: Bearer {{OAUTH2_ACCESS_TOKEN}}"The proxy substitutes placeholders in headers, the query string and the body, as the request leaves. A credential value is never returned toward the client.
Reserved credential names
| Name | Meaning |
|---|---|
OAUTH2_ACCESS_TOKEN | The provider's OAuth access token, if it supports OAuth |
OAUTH2_REFRESH_TOKEN | The refresh token. Proxies MUST NOT release this to a self-hosted proxy. |
| anything else | A named API-key field belonging to the provider |
Against a provider that issues API keys only, OAUTH2_ACCESS_TOKEN resolves to
that provider's default credential rather than failing. Most providers in any
real catalogue are API-key-only, so treating the OAuth names as strictly OAuth
would make the documented placeholder unusable for the majority of them.
3. Request shape
POST https://proxy.interagentic.dev/api.github.com/user/repos
X-Proxy-Authorization: Bearer <agent jwt>
Authorization: Bearer {{OAUTH2_ACCESS_TOKEN}}The target host is the first path segment; the rest of the path and the query string are forwarded unchanged.
| Header | Direction | Purpose |
|---|---|---|
X-Proxy-Authorization | request | The agent's identity token |
X-Proxy-Target-URL | request | Alternative to path-encoding the target; takes precedence |
X-Proxy-Scopes | request | Override the inferred scopes |
X-Proxy-Account | request | Choose between several connected accounts |
X-Proxy-Stream | request | Use the streaming forwarder |
The identity token is deliberately not in Authorization: that header has to
stay free to carry the placeholder.
Every X-Proxy-* header is stripped before the request goes upstream.
4. Scopes
<provider>::<scope> github.com::repo
<provider>::key::<FIELD_NAME> lifx.com::key::LIFX_PERSONAL_ACCESS_TOKENScopes are inferred from the method and path unless stated with
X-Proxy-Scopes. Inference is a convenience; a caller that knows exactly what
it needs should say so, because a narrower grant is a better thing to ask a
human for.
5. Refusals
When nothing is connected, or the connected grant is too narrow:
403 Forbidden
Content-Type: application/json
{
"error": "permission_denied",
"missingScopes": [{ "provider": "github.com", "scopes": ["repo"] }],
"authorizationUrl": "https://id.interagentic.dev/link/9f2c…"
}| Code | Condition |
|---|---|
401 | Missing or invalid X-Proxy-Authorization |
403 permission_denied | Credential not connected, or scope not granted |
403 credential_fetch_failed | Connected, but the upstream refused to issue a token |
409 ambiguous_account | Several accounts match and no X-Proxy-Account was given |
400 unknown_template_variable | The provider declares its fields and none match |
502 | The upstream API failed |
An ambiguous account is refused rather than guessed. Picking one silently would mean the agent sometimes acts as the wrong person.
6. Self-hosting the proxy
The substitution step can run on your own infrastructure. It asks the credential service only for the values it needs:
POST /api/proxy/resolve
{ "targetUrl": "https://api.github.com/user",
"method": "GET",
"placeholders": ["OAUTH2_ACCESS_TOKEN"] }{ "credentials": { "OAUTH2_ACCESS_TOKEN": "gho_abc…" }, "provider": "github" }Credentials come back keyed by placeholder name, not by provider, so a self-hosted proxy never learns which provider a value belongs to. Request and response bodies never leave your infrastructure; the hosted side sees only which provider and which scopes.
Refresh tokens are never released this way.
7. Provider actions
The outcome-oriented way in: named actions with typed inputs, implemented in open-source TypeScript and maintained by the agents that use them.
npx interagentic providers gmail ls
npx interagentic providers gmail messages list --query "is:unread"
npx interagentic providers gmail "archive everything older than a year"Each action is an immutable version identified by a content hash, carrying its
files, an input schema, an output schema, and a SKILL.md of accumulated
gotchas. Lifecycle is documented → developed → tested → reliable.
The natural-language form generates a missing action using your model key and publishes it, so the next caller runs it for free.
Self-repair
When a run fails — because the remote API changed, or because the action's code has a bug — an agent is spawned with the caller's model key to analyse the failure. It writes a fix and opens a pull request against the action:
gmail/messages-list · pull request #214
TypeError: Cannot read properties of undefined (reading 'map')
- return res.messages.map(toMessage);
+ // Gmail omits "messages" entirely when nothing matches.
+ return (res.messages ?? []).map(toMessage);Every pull request is run against the action's benchmark inputs and against the current version, and a regression fails the check. Once merged, the fix is a new content-hashed version, and every caller gets it on their next run.
Model keys
A model key is used only to write code: generating a missing action, or
repairing one that broke. Running a working action never needs one. The CLI
uses whichever of ANTHROPIC_API_KEY, OPENAI_API_KEY or OPENROUTER_API_KEY
is set.