---
title: "Declare a connection"
description: "Define per-call credentials for a plugin and read them in tools."
canonical_url: "https://docs.oz0.ai/build/parts/connections"
---
# Declare a connection

> Define per-call credentials for a plugin and read them in tools.

For: plugin authors.

Declare a connection when plugin tools act on another system as the tenant. The connection describes which tools receive tokens, the values an admin supplies, browser setup steps, token creation and the permissions of each function's token.

The edge executes this configuration. Your plugin reads a token for each call rather than requesting a long-lived key through `requires.secrets`. Admins connect it under Credentials, Connections in the console; see [connection administration](https://docs.oz0.ai/admin/connections).

[ai.oz0/github](https://docs.oz0.ai/build/plugins/github)'s connection, a GitHub App registered from a manifest and installed on an organization, is the full example: see `plugins/github/oz0-plugin.yaml` in your checkout. That file currently requires repository access; public source downloads are coming soon.

## Declare one

An OAuth 2.0 app with PKCE, whose bot token the exchange answers:

```yaml [oz0-plugin.yaml]
connections:
  - name: forge                          # unique in the tenant: /connect/<tenant>/forge/callback
    description: The Forge workspace your agents post to
    tools: [acme_forge]                  # this plugin's tools whose calls get its token
    params:
      - {name: client_id, description: The OAuth app's client ID, required: true}
      - {name: api_url, default: "https://api.forge.example"}
    steps:
      - name: authorize
        description: Forge asks which workspace to add the bot to
        browser:
          url: "https://forge.example/oauth/authorize"
          query:
            client_id: "{params.client_id}"
            redirect_uri: "{callback_url}"
            scope: "chat:write"
            code_challenge: "{pkce.challenge}"
            code_challenge_method: S256
        pkce: S256
        callback: [code]                 # what the provider's redirect must carry (state is always checked)
        exchange:                        # made by the edge, server to server
          method: POST
          url: "{params.api_url}/oauth/token"
          form:
            code: "{callback.code}"
            code_verifier: "{pkce.verifier}"
            redirect_uri: "{callback_url}"
          auth: basic
          basic: {user: "{params.client_id}", password: "{secret.FORGE_CLIENT_SECRET}"}
          expect: {ok: "true"}           # the answer must say so, or the step fails with its error
        save:
          secrets: {FORGE_BOT_TOKEN: "{answer.access_token}"}   # sealed for the edges
          values: {workspace: "{answer.team.name}"}             # shown on the Connections tab
    token:
      kind: secret                       # calls get a tenant secret...
      secret: FORGE_BOT_TOKEN            # ...the one the step saved
      pass: {api_url: "{params.api_url}"}
    scopes:
      - functions: [acme_forge.post_message, acme_forge.list_channels]
```

The admin sets `FORGE_CLIENT_SECRET` once (`oz0 secret set FORGE_CLIENT_SECRET`), then connects.

### The fields

<table>
<thead>
  <tr>
    <th>
      Field
    </th>
    
    <th>
      Meaning
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        name
      </code>
    </td>
    
    <td>
      Lowercase letters, digits and dashes; in the callback's URL. The tenant secrets its steps save start with it in capitals: <code>
        FORGE_*
      </code>
      
      .
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        tools
      </code>
    </td>
    
    <td>
      This plugin's tools whose calls may get its token.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        params
      </code>
    </td>
    
    <td>
      What an admin gives when connecting: <code>
        name
      </code>
      
      , <code>
        description
      </code>
      
      , <code>
        default
      </code>
      
      , <code>
        required
      </code>
      
      , and <code>
        choices
      </code>
      
       for a param that is one of a few values, which the console shows as a select. <code>
        max_length
      </code>
      
       limits a param such as an app name; a <code>
        {tenant}
      </code>
      
       default is shortened to fit.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        steps[]
      </code>
    </td>
    
    <td>
      Done in order, each in the admin's browser and back to the edge's callback.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        steps[].when
      </code>
    </td>
    
    <td>
      The params and values the step runs with, such as <code>
        {account: user}
      </code>
      
      : a flow skips a step whose params differ, so one connection can take another path for another kind of account (ai.oz0/github registers an app on an organization or on a user with two steps). A step needs the params it reads; a param only some steps read need not be <code>
        required
      </code>
      
      .
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        steps[].browser
      </code>
    </td>
    
    <td>
      Redirects or posts a form to the other system. See <a href="#browser-steps-and-allowed-urls">
        browser fields and URL checks
      </a>
      
      .
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        steps[].pkce
      </code>
    </td>
    
    <td>
      <code>
        S256
      </code>
      
       makes a PKCE pair for the step: <code>
        {pkce.challenge}
      </code>
      
       for the browser, <code>
        {pkce.verifier}
      </code>
      
       for the exchange.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        steps[].callback
      </code>
    </td>
    
    <td>
      The query parameters the provider's redirect must bring. An <code>
        error
      </code>
      
       parameter (OAuth's) fails the step with the provider's words.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        steps[].exchange
      </code>
    </td>
    
    <td>
      A request the edge makes when the callback came: <code>
        method
      </code>
      
      , <code>
        url
      </code>
      
      , <code>
        query
      </code>
      
      , <code>
        headers
      </code>
      
      , and a <code>
        form
      </code>
      
       or <code>
        json
      </code>
      
       body; <code>
        auth: jwt
      </code>
      
       (the token's JWT) or <code>
        auth: basic
      </code>
      
      . Its JSON answer is <code>
        {answer.*}
      </code>
      
      . <code>
        expect
      </code>
      
      , such as <code>
        {ok: "true"}
      </code>
      
      , are fields the answer must have, for an API that answers 200 with <code>
        {"ok": false, "error": ...}
      </code>
      
      : the step then fails with that error and saves nothing.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        steps[].save
      </code>
    </td>
    
    <td>
      <code>
        secrets
      </code>
      
      : tenant secrets, sealed for the edges, named with the connection's prefix. <code>
        values
      </code>
      
      : kept on the connection, where people see them; never put a secret here.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        token.kind
      </code>
    </td>
    
    <td>
      <code>
        secret
      </code>
      
      : calls get the tenant secret <code>
        token.secret
      </code>
      
      . <code>
        jwt-exchange
      </code>
      
      : the edge signs an RS256 JWT with the saved key <code>
        token.jwt.key
      </code>
      
       (issuer <code>
        token.jwt.issuer
      </code>
      
      , <code>
        ttl
      </code>
      
       at most 10m) and asks <code>
        token.url
      </code>
      
       for a token, sending the function's scope in <code>
        scope_field
      </code>
      
       and its resource in <code>
        resource_field
      </code>
      
      , and reading <code>
        token_field
      </code>
      
       and <code>
        expires_field
      </code>
      
       of the answer.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        token.kind: client-credentials
      </code>
    </td>
    
    <td>
      The edge requests a service token with a client ID and secret bound to the destination. See <a href="#client-credentials-grants">
        grant fields and restrictions
      </a>
      
      .
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        token.kind: identity
      </code>
    </td>
    
    <td>
      An OIDC token from the tenant issuer, without setup steps or a stored secret. See <a href="#identity-token-fields">
        identity fields
      </a>
      
      .
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        token.kind: on_behalf_of
      </code>
    </td>
    
    <td>
      Exchanges a delegated identity token for the service token needed by a call. See <a href="#on-behalf-of-token-fields">
        exchange fields
      </a>
      
      .
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        token.refresh
      </code>
    </td>
    
    <td>
      Renews a <code>
        kind: secret
      </code>
      
       token a step saved, before it expires: <code>
        url
      </code>
      
       (the token endpoint, a literal <code>
        https://&lt;host>/
      </code>
      
       or <code>
        https://{params.&lt;name>}/
      </code>
      
      ), <code>
        secret
      </code>
      
       (the tenant secret a step saves the refresh token in, such as <code>
        {answer.refresh_token}
      </code>
      
      ), <code>
        client_id
      </code>
      
      , optionally <code>
        client_secret
      </code>
      
       (a confidential client's secret; leave it out for a public client with PKCE), <code>
        scope
      </code>
      
       and <code>
        resource
      </code>
      
       (RFC 8707, such as the MCP server's URL). See <a href="https://docs.oz0.ai/build/parts/connections/token-lifecycle#refresh-tokens">
        Refresh tokens
      </a>
      
      .
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        token.fallback_secret
      </code>
    </td>
    
    <td>
      A tenant secret calls get while the steps are not done, such as <code>
        GITHUB_TOKEN
      </code>
      
      : one an admin sets by hand, so no step may save it. Like <code>
        token.secret
      </code>
      
      , it is named with the connection's prefix, and neither may be <code>
        token.jwt.key
      </code>
      
      .
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        token.pass
      </code>
    </td>
    
    <td>
      Values the tool gets with the token, such as the API's address. Never secrets.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        after[]
      </code>
    </td>
    
    <td>
      Functions of the connection's tools that run once each time its last step is done: <code>
        call
      </code>
      
       (<code>
        &lt;tool>.&lt;function>
      </code>
      
      ) and <code>
        arguments
      </code>
      
       (templates of <code>
        {params.*}
      </code>
      
       and <code>
        {values.*}
      </code>
      
      ). The edge starts each as a tool job, through the node's policy, hooks and approvals, with the new token; the Connections tab shows the jobs. Use it to check what was connected, or to register a bot's commands.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        scopes[]
      </code>
    </td>
    
    <td>
      Which functions get a token: <code>
        functions
      </code>
      
       (<code>
        &lt;tool>.*
      </code>
      
       for every function of a remote tool on the connection, which its server lists), and for <code>
        jwt-exchange
      </code>
      
       the <code>
        scope
      </code>
      
       (an object, such as GitHub's permissions) and the <code>
        resource
      </code>
      
       its arguments name, such as `"{arguments.repo
    </td>
  </tr>
</tbody>
</table>

### Browser steps and allowed URLs

A browser step uses `url` and `query` for a redirect. With `method: POST`, it uses `fields` and `json_fields` for a form posted by the edge's page. A JSON field contains a JSON document made from the object, such as a GitHub App's manifest. The step's `state` is added to the query.

The browser URL, exchange URL, check URL and guided step's create link must start with `https://`, `http://`, or a param holding such an address. Lint refuses other schemes, including `javascript:`, `data:` and `file:`, because they could run in or leave the edge's page instead of reaching the other system. The edge also refuses a filled-in address that is not a web URL.

### Client credentials grants

The edge makes an OAuth 2.0 client credentials grant (RFC 6749 section 4.4). It sends a form `POST` to `token.url` with `grant_type=client_credentials`, `client_id` from `token.client_id` (a template of params and values), the client secret named by `token.client_secret`, and `scope` from `token.scope`. The tenant secret uses the connection's prefix, stays with the edge rather than reaching the node, and cannot be listed in `requires.secrets`.

The answer's `access_token`, or `token_field`, is the call's token until `expires_in` seconds, or `expires_field`, have elapsed. The edge caches it while more than ten minutes remain.

`token.url` starts with a literal `https://<host>/`. For a customer's own instance, it may use `https://{params.<name>}/`, such as a ServiceNow instance's `/oauth_token.do`, when a guided step pastes the secret. The edge records the instance's origin at that paste and sends the secret only there. Every guided check using `{paste.<field>}` sends it to the same origin.

Changing the host param, or installing a plugin version whose `token.url` names another host, requires pasting the secret again before another grant. Only the grant reads the saved secret: lint and the edge refuse `{secret.<client secret>}` in any template, and refuse that secret as `fallback_secret`.

ai.oz0/teams gets its bot token from Microsoft Entra this way.

### Identity token fields

For `token.kind: identity`, the edge creates an OIDC identity token for the call from the tenant's issuer. There is no step, secret or admin setting.

`token.profile` is `workload`, whose subject is the agent, or `delegated`, whose subject is the person or routine's team the job runs for and whose actor is the agent. `token.audience` names the one service: an `https://` URL, `urn:` or `api://` name. It can use params so an admin can name the service.

This kind takes no `url`, `scope`, client or fallback. See [Identity tokens](https://docs.oz0.ai/build/parts/connections/token-lifecycle#identity-tokens).

### On-behalf-of token fields

For `token.kind: on_behalf_of`, the edge exchanges a delegated identity token for a service's own token on each call. The exchange uses `token.url`, the service's RFC 8693 token endpoint, as a literal `https://<host>/`. The agent's workload token is the actor.

`token.audience` sets the delegated token's audience, defaulting to the endpoint URL. `token.scope` sets the requested scope. If the endpoint needs client credentials, `token.client_id` and `token.client_secret` supply them. As with client credentials grants, the secret goes only to the origin for which it was pasted.

### A private key instead of a client secret

Some directories take no client secret for their management scopes, only a client assertion signed with the client's private key (RFC 7523, `private_key_jwt`). With `client_auth: private_key_jwt`, the edge signs one for each grant with the RSA key in the tenant secret `token.client_secret` names, and sends the assertion instead of a secret: `iss` and `sub` the client ID, `aud` the token endpoint, five minutes, and a fresh `jti`. `client_key_id` is its `kid`, a template of params and values; a pasted JWK's own `kid` is used without one.

The key may be a PEM file (PKCS #1 or #8), a PEM pasted into a one-line field, or a private JWK as a directory's console hands it out; it must be at least 2048 bits. Since the key itself goes nowhere, `token.url` may name its host with one param (a customer's own domain, `https://{params.domain}/oauth2/v1/token`); the edge records the origin when the key is pasted and signs nothing for another one until it is pasted again, and no guided check may send the pasted key anywhere.

A directory whose certificate credentials name the key by its certificate takes the key and its certificate pasted together, both PEM blocks in one field (in either order). The edge then signs the assertion PS256 (RSASSA-PSS), puts the certificate's `x5t#S256` thumbprint (the base64url SHA-256 of its DER) in the header, beside `kid` when `client_key_id` names one, and adds `nbf`. A certificate of another key fails the grant with an error that says so. The directory holds only the certificate, and no secret travels with any grant.

```yaml [oz0-plugin.yaml]
token:
  kind: client-credentials
  url: "https://{params.domain}/oauth2/v1/token"
  client_id: "{values.client_id}"
  client_secret: DIRECTORY_PRIVATE_KEY
  client_auth: private_key_jwt
  scope: "directory.manage"
```

## Read the token in a call

A remote tool on the connection (`url:` with `connection:`) gets the token in each call's `Authorization` header instead, and nothing in `_meta` ([Remote servers on connections](https://docs.oz0.ai/build/parts/tools#remote-servers-on-connections)). Every other call of a function that a scope lists carries its connection's token in the MCP request's `_meta`, under `oz0/connections`: `{"<connection>": {"token": "...", "kind": "app" | "secret" | "identity" | "on_behalf_of" | "federated", "expires_at": "...", ...pass}}`. With the Python MCP SDK:

```python [src/acme_forge/server.py]
from mcp.server import MCPServer
from mcp.server.mcpserver import Context

server = MCPServer("acme_forge")


def token(ctx: Context) -> tuple[str, str]:
    meta = ctx.request_context.meta or {}
    forge = (meta.get("oz0/connections") or {}).get("forge") or {}
    return forge.get("token", ""), forge.get("api_url", "https://api.forge.example")


@server.tool()
def post_message(channel: str, text: str, ctx: Context) -> str:
    """Post a message to a channel."""
    bearer, api = token(ctx)
    ...
```

In `oz0 dev` and `oz0 plugin test`, a connection's `fallback_secret` that is set in your environment, such as `GITHUB_TOKEN`, is copied into the dev server's tenant, as declared secrets are, so your tools get it in each call there too.

The token is in no environment variable and never reaches the job's history. A call whose connection gives it no token (not connected, no fallback secret set) is not made: the agent reads that an admin connects it, and how.

## Related tasks

- [Connection token lifecycle](https://docs.oz0.ai/build/parts/connections/token-lifecycle): Refresh credentials, request identity tokens and handle long calls.
- [Connection setup and security](https://docs.oz0.ai/build/parts/connections/setup-and-security): Build guided connection steps and understand edge enforcement.

<legacy-sections>

- <span id="clouds">

Section

</span>

: [clouds](https://docs.oz0.ai/build/parts/connections/token-lifecycle#clouds)
- <span id="guided-steps">

Section

</span>

: [guided steps](https://docs.oz0.ai/build/parts/connections/setup-and-security#guided-steps)
- <span id="identity-tokens">

Section

</span>

: [identity tokens](https://docs.oz0.ai/build/parts/connections/token-lifecycle#identity-tokens)
- <span id="long-calls-as-tasks">

Section

</span>

: [long calls as tasks](https://docs.oz0.ai/build/parts/connections/token-lifecycle#long-calls-as-tasks)
- <span id="refresh-tokens">

Section

</span>

: [refresh tokens](https://docs.oz0.ai/build/parts/connections/token-lifecycle#refresh-tokens)
- <span id="rules-the-edge-keeps">

Section

</span>

: [rules the edge keeps](https://docs.oz0.ai/build/parts/connections/setup-and-security#rules-the-edge-keeps)
- <span id="templates">

Section

</span>

: [templates](https://docs.oz0.ai/build/parts/connections/setup-and-security#templates)

</legacy-sections>


## Sitemap

See the full [sitemap](https://docs.oz0.ai/sitemap.md) for all pages.
