Parts

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.

ai.oz0/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:

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

FieldMeaning
nameLowercase letters, digits and dashes; in the callback's URL. The tenant secrets its steps save start with it in capitals: FORGE_*.
toolsThis plugin's tools whose calls may get its token.
paramsWhat an admin gives when connecting: name, description, default, required, and choices for a param that is one of a few values, which the console shows as a select. max_length limits a param such as an app name; a {tenant} default is shortened to fit.
steps[]Done in order, each in the admin's browser and back to the edge's callback.
steps[].whenThe params and values the step runs with, such as {account: user}: 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 required.
steps[].browserRedirects or posts a form to the other system. See browser fields and URL checks.
steps[].pkceS256 makes a PKCE pair for the step: {pkce.challenge} for the browser, {pkce.verifier} for the exchange.
steps[].callbackThe query parameters the provider's redirect must bring. An error parameter (OAuth's) fails the step with the provider's words.
steps[].exchangeA request the edge makes when the callback came: method, url, query, headers, and a form or json body; auth: jwt (the token's JWT) or auth: basic. Its JSON answer is {answer.*}. expect, such as {ok: "true"}, are fields the answer must have, for an API that answers 200 with {"ok": false, "error": ...}: the step then fails with that error and saves nothing.
steps[].savesecrets: tenant secrets, sealed for the edges, named with the connection's prefix. values: kept on the connection, where people see them; never put a secret here.
token.kindsecret: calls get the tenant secret token.secret. jwt-exchange: the edge signs an RS256 JWT with the saved key token.jwt.key (issuer token.jwt.issuer, ttl at most 10m) and asks token.url for a token, sending the function's scope in scope_field and its resource in resource_field, and reading token_field and expires_field of the answer.
token.kind: client-credentialsThe edge requests a service token with a client ID and secret bound to the destination. See grant fields and restrictions.
token.kind: identityAn OIDC token from the tenant issuer, without setup steps or a stored secret. See identity fields.
token.kind: on_behalf_ofExchanges a delegated identity token for the service token needed by a call. See exchange fields.
token.refreshRenews a kind: secret token a step saved, before it expires: url (the token endpoint, a literal https://<host>/ or https://{params.<name>}/), secret (the tenant secret a step saves the refresh token in, such as {answer.refresh_token}), client_id, optionally client_secret (a confidential client's secret; leave it out for a public client with PKCE), scope and resource (RFC 8707, such as the MCP server's URL). See Refresh tokens.
token.fallback_secretA tenant secret calls get while the steps are not done, such as GITHUB_TOKEN: one an admin sets by hand, so no step may save it. Like token.secret, it is named with the connection's prefix, and neither may be token.jwt.key.
token.passValues the tool gets with the token, such as the API's address. Never secrets.
after[]Functions of the connection's tools that run once each time its last step is done: call (<tool>.<function>) and arguments (templates of {params.*} and {values.*}). 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.
scopes[]Which functions get a token: functions (<tool>.* for every function of a remote tool on the connection, which its server lists), and for jwt-exchange the scope (an object, such as GitHub's permissions) and the resource its arguments name, such as `"{arguments.repo

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.

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.

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). 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:

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.

Previous section links
Copyright © 2026