Credential grants
Call an external API with a user credential that your code cannot read
What is a credential grant?
A credential grant is a named credential that belongs to one user. It has two parts:
- Secret: the token, key, or password
- Policy: the destinations that can receive the secret and the code that can use the grant
Your code calls fetch(grant, input, init) with the grant name. Standard Agents Builder adds the secret to the outgoing request, removes the secret from the response, and returns the response. Your code, the model, and the client receive no copy of the secret.
Use a grant for a credential that authenticates one user to an external HTTPS API, such as that user's GitHub token.
Creating a grant
An administrator creates or replaces a grant with one request:
PUT /api/users/:id/grants/:namecurl -X PUT "https://your-instance.example.com/api/users/$USER_ID/grants/github" \
-H "Authorization: Bearer $STANDARD_AGENTS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"kind": "bearer",
"secret": "ghp_example",
"origins": ["https://api.github.com"],
"allow": {
"userEndpoints": ["github/repos"],
"agents": ["repo_agent"]
},
"metadata": { "account": "octocat" }
}'A successful request returns HTTP 204 with an empty body. A second PUT to the same name replaces the secret and the policy.
Request body
| Field | Required | Description |
|---|---|---|
kind |
Yes | bearer, header, query, or basic. See Kinds and injection |
secret |
Yes | The credential. A string of 1 to 16,384 characters. A bearer or header secret cannot contain a line break |
origins |
Yes | 1 to 64 destinations that can receive the secret. See Origin matching |
allow |
Yes | The callers that can use the grant. See Allow policy |
inject |
No | Where the secret goes in the request: header, format, and query |
metadata |
No | A JSON object of non-secret data, such as an account label. At most 16,384 characters as JSON |
name |
No | When present, it must equal the name in the path |
A grant name starts with a lowercase letter or a digit. The remaining characters are lowercase letters, digits, ., _, and -. The maximum length is 128 characters.
The request returns HTTP 400 with
{ "error": { "code": "bad_request", "message": "Invalid credential grant" }, "requestId": "..." } when a field fails these rules or when the body has a field that this table does not list. It returns HTTP 404 when the user does not exist and HTTP 403 when the caller is not an administrator.
Listing grants
GET /api/users/:id/grantsThe response lists the policy of each grant, sorted by name. It contains no secret:
{
"grants": [
{
"name": "github",
"kind": "bearer",
"origins": ["https://api.github.com"],
"allow": {
"userEndpoints": ["github/repos"],
"agents": ["repo_agent"]
},
"metadata": { "account": "octocat" }
}
]
}The list also contains the instance grants that apply to the user.
Deleting a grant
DELETE /api/users/:id/grants/:nameThe request returns HTTP 204, including when the user has no grant with that name. It deletes the user's own grant only.
- When no instance grant has that name,
fetch()with that grant name returns thegrant_not_founderror after the deletion. - When an instance grant has that name, it applies to the user again after the deletion.
fetch()then uses the secret and the policy of the instance grant.
To revoke a credential that an instance grant supplies, remove the grant from the instance registry or change its allow policy.
The secret is write-only
The PUT request is the one operation that accepts a secret. No route returns it, and UserState has no member that reads it. To change a secret, send a new PUT request with the full grant.
Standard Agents Builder stores each grant in the durable storage of the user it belongs to. The policy is stored next to the secret, and the runtime reads the secret only when it adds the credential to a fetch() request.
The three grant routes accept administrators only. The policy, including metadata, is readable through GET /api/users/:id/grants. Code in agents/ cannot list grants or read their policy.
Kinds and injection
kind sets how the runtime adds the secret to the request. inject adjusts the location for the header and query kinds.
| Kind | Injection | inject fields |
|---|---|---|
bearer |
Sets Authorization: Bearer <secret> |
None |
header |
Sets one header to the secret | header names the header. The default is Authorization. format is the header value, and the runtime replaces each SECRET in it with the secret. The default is SECRET |
query |
Sets one query parameter to the secret | query names the parameter. The default is key |
basic |
Sets Authorization: Basic <base64 of the secret> |
None |
For the basic kind, store the secret as username:password.
A header grant for an API that expects X-Api-Key: <secret>:
{
"kind": "header",
"secret": "sk_example",
"origins": ["https://api.example.com"],
"inject": { "header": "X-Api-Key" },
"allow": { "agents": ["support_agent"] }
}A header grant for an API that expects Authorization: token <secret>:
{
"kind": "header",
"secret": "ghp_example",
"origins": ["https://api.github.com"],
"inject": { "format": "token SECRET" },
"allow": { "agents": ["repo_agent"] }
}The injected header or query parameter replaces a value with the same name that the caller supplied.
Rules for inject:
headeris a valid HTTP header name.formathas at most 1,024 characters and no line break.queryhas 1 to 128 characters from letters, digits,.,_,~, and-.
Standard Agents Builder does not support the signing kind. A PUT request with "kind": "signing" returns HTTP 400.
Origin matching
The runtime sends the secret only to a destination that an origins entry covers. An entry has one of two forms:
| Form | Example | Matches |
|---|---|---|
| Exact origin | https://api.github.com |
A URL with the same scheme, host, and port |
| Suffix wildcard | *.example.com |
An https URL whose host name ends with .example.com |
- An exact origin uses
https. Write it in lowercase with no path, no trailing slash, and no user name or password. - A wildcard entry does not match the bare domain.
*.example.commatchesapi.example.comand does not matchexample.com. Addhttps://example.comas a second entry to cover it. - Each entry has at most 512 characters.
The runtime checks the origin before it checks the caller.
Redirects
The runtime follows redirects itself and checks each redirect destination against origins.
- A redirect to a destination that no
originsentry covers ends the fetch with thegrant_origin_deniederror. The runtime sends no request to that destination. - The runtime adds the credential again to each request that it sends, and it sends the headers from your code with each one.
- The runtime follows at most 5 redirects. A sixth redirect ends the fetch with the
grant_redirect_limiterror. - A 303 response changes the method to
GET, except for aGETorHEADrequest. A 301 or 302 response does the same for aPOSTrequest. The runtime then removes the body and the headers that describe it, such asContent-Type. A 307 or 308 response keeps the method and the body. - The runtime does not forward a
redirectoption from your code.
Allow policy
allow names the code that can use the grant. Each field is a list of up to 128 names. Each name has 1 to 256 characters. A caller that no list names receives the grant_caller_denied error. A PUT request needs at least one list.
| Field | Caller | Name to list |
|---|---|---|
userEndpoints |
A user endpoint that calls user.fetch() |
The endpoint path under agents/users/, such as github/repos. The name with a method suffix, such as github/repos.post, limits the entry to that file |
agents |
Thread code that calls state.user() and then fetch() |
The name of the agent that the thread runs |
Thread code means tools, hooks, handlers, effects, and thread endpoints. Standard Agents Builder identifies all of them by the agent of the thread, so allow.agents is the list that admits them.
Two more callers use the userEndpoints list:
- A user lifecycle hook matches its hook type, such as
user_value_changed. - A user room matches the name in its
endpointoption. A room without that option matches its path underagents/users/, such asmy_package/room.
The PUT route also accepts allow.tools and allow.endpoints and stores them. No caller in Standard Agents Builder matches those two lists. The route rejects allow.userRooms.
Using a grant
fetch(grant, input, init) takes the grant name, a URL, and request options. It is a member of UserState and of the handle that state.user() returns.
From a user endpoint
// agents/users/github/repos.ts
import { defineUserEndpoint } from '@standardagents/spec';
export default defineUserEndpoint(async (_req, user) => {
const response = await user.fetch('github', 'https://api.github.com/user/repos', {
headers: { Accept: 'application/vnd.github+json' },
});
if (!response.ok) {
return Response.json({ error: 'The GitHub request failed.' }, { status: 502 });
}
return Response.json(await response.json());
});This endpoint needs github/repos in allow.userEndpoints.
From a tool
// agents/tools/create_github_issue.ts
import { defineTool } from '@standardagents/spec';
import { z } from 'zod';
export default defineTool({
description: 'Create an issue in one of the user\'s GitHub repositories.',
args: z.object({
repo: z.string().describe('The repository, as owner/name'),
title: z.string().describe('The issue title'),
}),
execute: async (state, args) => {
const user = await state.user();
if (!user) {
return { status: 'error', error: 'This thread has no user.' };
}
const response = await user.fetch('github', `https://api.github.com/repos/${args.repo}/issues`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: args.title }),
});
if (!response.ok) {
return { status: 'error', error: `GitHub returned HTTP ${response.status}.` };
}
return { status: 'success', result: await response.text() };
},
});This tool needs the thread's agent name in allow.agents.
How the runtime handles the request
- The runtime reads the user's grant with that name. When the user has none, it reads the instance grant with that name.
- It checks the destination against
origins. - It checks the caller against
allow. - It adds the secret to the request as
kindandinjectdescribe. - It sends the request with redirects disabled.
- For a redirect response, it checks the new destination against
origins, adds the secret again, and sends the next request. It repeats this step for at most 5 redirects. - It removes the credential from the final response and returns that response to your code. See Response scrubbing.
The runtime forwards the method, headers, and body options. The default method is GET. body is a string, null, or absent. fetch() throws for any other body type.
Response scrubbing
A destination in origins is trusted to receive the secret. It is not trusted to return it. An API can echo the request that it received, for example in a debug field, an error message, or a header. The runtime therefore removes the credential from every response before your code reads it.
The runtime replaces each occurrence of the credential with [redacted]:
- In the value of every response header. A header whose name contains the credential is removed.
- In the response body, as a stream. A credential that spans two chunks is replaced. A chunk without a possible match is passed on at once, so streamed responses are not delayed.
- In the final response of a redirect chain, and in error responses such as HTTP 401 or 500. Your code never receives a redirect response that the runtime followed.
The response has no url and no status text, because both can hold the credential. It has no Content-Length header, because a replacement changes the length of the body. A stored checksum of the body, such as an ETag, describes the body before the replacement.
The runtime matches these forms of the credential:
| Form | Example of where it appears |
|---|---|
| The secret as stored | Bearer <secret>, a format value, a plain text body |
| Percent-encoded and form-encoded, with uppercase hexadecimal digits | A URL that holds a query credential |
| JSON-escaped | A JSON body that quotes a secret with " or \ in it |
Base64 and base64url of the secret's UTF-8 bytes, and for basic of the one-byte-per-character (Latin-1) encoding that Authorization: Basic uses |
The Authorization: Basic value |
For basic, the password after the first :, in each form above |
A body that returns the decoded password. A password shorter than 8 characters is not matched alone |
The match is on bytes. The runtime decodes gzip and br bodies before the scan. A response body with another Content-Encoding, such as deflate or zstd, cannot be scanned. The fetch then ends with the grant_response_encoding_unsupported error, and your code receives no part of that body.
The runtime does not match a credential that the destination changed in another way:
- Text in a character encoding other than UTF-8, such as UTF-16
- Percent-encoding with lowercase hexadecimal digits, hexadecimal, or another escape format
- Base64 of a longer string that contains the secret
- A secret that is split, reversed, hashed, encrypted, or inside a compressed or binary container such as a ZIP file or an image
- For
basic, the user name alone
Scrubbing stops an API that reflects its request. It cannot stop a destination that transforms the secret in order to return it. List only destinations that you trust with the credential.
Errors
When the runtime does not send the request, or cannot return the response, fetch() resolves with a JSON response that carries an error code:
| Status | Body error |
Cause |
|---|---|---|
| 404 | grant_not_found |
No grant with that name applies to the user. The body also has the grant name |
| 400 | grant_target_invalid |
input is not a valid absolute URL |
| 403 | grant_origin_denied |
No origins entry covers the destination, or the destination of a redirect |
| 403 | grant_caller_denied |
No allow list names the caller |
| 500 | grant_config_invalid |
The stored policy cannot be read |
| 501 | grant_kind_unsupported |
The grant has a kind that the runtime cannot inject |
| 502 | grant_redirect_limit |
The external API returned more than 5 redirects in a row |
| 502 | grant_fetch_failed |
The request to the external API failed at the network level |
| 502 | grant_response_encoding_unsupported |
The response body has a Content-Encoding that the runtime cannot scan for the credential |
The external API can return the same status codes. Read the error field of the body to tell a grant error from an API response. The error bodies contain neither the secret nor the destination URL.
Instance grants
An instance grant applies to every user of the instance. Set the secret AGENT_BUILDER_CREDENTIAL_GRANTS to a JSON registry:
{
"version": 1,
"grants": [
{
"name": "weather",
"kind": "query",
"secret": "wk_example",
"origins": ["https://api.weather.example.com"],
"inject": { "query": "appid" },
"allow": { "agents": ["weather_agent"] }
}
]
}- Each entry follows the rules of the
PUTrequest body, andnameis required. - The registry holds at most 64 grants and at most 65,536 characters.
- When the registry is malformed, or two entries share a name, the runtime uses no instance grants.
- A user's own grant takes precedence over an instance grant with the same name. After the user's grant is deleted, the instance grant applies again.
Grant or variable
state.env(name) and user.env(name) return the value of a variable to your code. Code that holds a value can log it, return it to the model, or send it to any destination.
| Use | When |
|---|---|
| Credential grant | The credential authenticates one user to an external HTTPS API, and the API accepts it in a header, a query parameter, or Basic authentication |
| Thread or user variable | Your code needs the value itself: to pass it to an SDK, to compute a signature, to send a request body that is not a string, or to store configuration that is not a secret |
A grant also limits the credential to listed destinations and listed callers. A variable has no such limits.