Skip to content

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/:name
curl -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/grants

The 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/:name

The 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 the grant_not_found error 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:

  • header is a valid HTTP header name.
  • format has at most 1,024 characters and no line break.
  • query has 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.com matches api.example.com and does not match example.com. Add https://example.com as 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 origins entry covers ends the fetch with the grant_origin_denied error. 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_limit error.
  • A 303 response changes the method to GET, except for a GET or HEAD request. A 301 or 302 response does the same for a POST request. The runtime then removes the body and the headers that describe it, such as Content-Type. A 307 or 308 response keeps the method and the body.
  • The runtime does not forward a redirect option 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 endpoint option. A room without that option matches its path under agents/users/, such as my_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

  1. The runtime reads the user's grant with that name. When the user has none, it reads the instance grant with that name.
  2. It checks the destination against origins.
  3. It checks the caller against allow.
  4. It adds the secret to the request as kind and inject describe.
  5. It sends the request with redirects disabled.
  6. 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.
  7. 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 PUT request body, and name is 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.

Next steps