Connection token lifecycle
For: plugin authors.
Refresh credentials, request identity tokens and handle long calls.
Refresh tokens
An authorization-code connection whose token answer carries a refresh token and expires_in is renewed by the edge before the token expires, so its tools keep working without anyone signing in again:
steps:
- name: authorize
browser:
url: "https://{params.host}/oauth/authorize"
query: {client_id: "{params.client_id}", response_type: code, redirect_uri: "{callback_url}",
code_challenge: "{pkce.challenge}", code_challenge_method: S256, resource: "https://{params.host}/mcp"}
pkce: S256
callback: [code]
exchange:
method: POST
url: "https://{params.host}/oauth/token"
form: {grant_type: authorization_code, code: "{callback.code}", client_id: "{params.client_id}",
code_verifier: "{pkce.verifier}", redirect_uri: "{callback_url}", resource: "https://{params.host}/mcp"}
save:
secrets: {TRACKER_ACCESS_TOKEN: "{answer.access_token}", TRACKER_REFRESH_TOKEN: "{answer.refresh_token}"}
token:
kind: secret
secret: TRACKER_ACCESS_TOKEN
refresh: {url: "https://{params.host}/oauth/token", secret: TRACKER_REFRESH_TOKEN, client_id: "{params.client_id}",
resource: "https://{params.host}/mcp"}
Before it expires
A call that finds less than ten minutes left on the token, or less than half the lifetime the token endpoint gave it (expires_in) when that is shorter, renews it first (RFC 6749 section 6); the others get the token in force. So a provider that issues five-minute tokens gets one refresh every two and a half minutes, not one on every call.
A token saved before the edge recorded when tokens were issued keeps the ten-minute rule until its next refresh. When the provider answers a new refresh token (rotation, which providers must do for public clients), it replaces the old one before the new token is used.
Once, however many calls and edges
On one edge, calls that find the token due at once wait for the first one's refresh and get its new token. Between edges, each refresh is claimed before it is made, under a name of the attempt's own, so of all the calls on every edge that find the token due at once, one spends the refresh token: the others hand out the token in force while it is still good, or wait a few seconds for the new one.
A refresh, once claimed, runs to its end even when the call that asked for it goes away, so a rotated refresh token is never lost. A provider that revokes a grant whose refresh token is presented twice never sees that from OZ.
Public clients
A PKCE client of the customer's own app, which has no client secret, refreshes with its client_id only. A confidential client names its client_secret, which goes with each refresh to the token endpoint and nowhere else. On a literal https://<host>/ endpoint an admin may set it by hand; on https://{params.<name>}/ a guided step must paste it, as for a client credentials grant: the edge records the host it was pasted for, sends it there only (in the refresh and in a step's exchange that reads {secret.<client secret>}), and a changed host param gets no refresh until it is pasted again.
Lint refuses an exchange or a check that would send it to another host.
- Where the refresh token goes. Only to the token endpoint it was issued at: the edge records that origin when a step saves the refresh token, and a host param changed since gets no refresh. The refresh token is a tenant secret sealed like the others, read by the refresh alone: never by a call, a template, a node (lint refuses it in
requires.secrets), a log or a job's history. - Refused. When the provider refuses a refresh (the refresh token was revoked, expired or spent), the connection needs reconnecting:
oz0 connection showand the Connections tab say why, the audit log hasconnection.reconnect, and the tenant's admins are told once (by email, and theconnection.reconnectnotification). Its tools' calls get no token until an admin connects it again, which clears it. A refresh the provider could not answer for now (5xx, 429, the network) leaves the token in force while it lasts and is tried again.
There is no dynamic client registration: a connection uses the customer's own OAuth app, registered with the provider for the edge's callback URL.
Identity tokens
A service that trusts the tenant's issuer needs no key from you: declare a connection whose token is the edge's own identity token, and each covered call gets a token of five minutes or less for that one service.
connections:
- name: orders
description: The orders service, which trusts the tenant's issuer
tools: [orders_api]
params:
- {name: api_url, description: "The orders service", default: "https://orders.example.com"}
token: {kind: identity, profile: workload, audience: "{params.api_url}", pass: {api_url: "{params.api_url}"}}
scopes:
- functions: [orders_api.list_orders, orders_api.refund]
The service verifies the token as it verifies any OIDC token: the issuer (iss) is the tenant's, its keys come from the
issuer's discovery document, the audience (aud) is its own, and exp has not passed. It authorizes on sub: the
agent, which stays the same across upgrades of the plugin and changes when the plugin is removed and installed again.
A service that authorizes people declares profile: delegated: the token's subject is the person who started the job
(or a routine's team), and act.sub the agent. A job that no person started gets no delegated token, and none leaves
the cluster until a tenant admin allows it (oz0 tenant oidc delegated-off-cluster). A service that wants its own
token for that person declares on_behalf_of:
token: {kind: on_behalf_of, url: "https://login.crm.example/oauth2/token", audience: "api://crm", scope: "crm.read"}
The edge mints an identity token only for a call it handed the asking node, with every claim from the job's own
record; nothing in the node's request becomes a claim. The token names the job's agent, so only the agent's own
plugin's connections get one: when an agent calls a tool of another plugin it was granted, that plugin's identity
connections give the call no token, and a trust policy written for the agent trusts no other plugin's code.
plugin lint warns about every connection whose tokens name a person.
Clouds
A tool that reaches AWS, Google Cloud or Azure declares a connection of kind federated: the edge exchanges the
agent's own token at the cloud, and the call gets a placeholder of the short-lived credential, which the node's egress
proxy puts on (or, for AWS, signs with). No key is stored and the tool never holds one:
token:
kind: federated
federated: {cloud: aws, role_arn: "{params.role_arn}", region: "{params.region}"}
The connection must be bound to the cloud's hosts in requires.inject. Only the tools of the agent's own plugin get
its cloud credentials: a job whose agent is another plugin's gets none from your connection. Fields, bindings and the
trust each cloud needs: Cloud federation.
Long calls as tasks
A remote server may take minutes or an hour over a call: a report, an export, a scan. Held open, such a call keeps one of the node's activity slots (orchestrator-zero-node run --activity-slots) and an HTTP request waiting the whole time. A server that speaks MCP's Tasks extension (io.modelcontextprotocol/tasks, on the 2026-07-28 revision) can answer such a call with a task instead, and look at it later. A tenant admin names the connections whose remote tools may do so:
oz0 tenant tasks default --connections reports --reason "the report server's exports run for an hour"
Then:
- The call declares the extension. Each call of a remote tool on a named connection says, in its request, that it takes a task; the server decides, call by call, whether to answer with one. A call of any other connection, or of a function whose listing forbids tasks, declares nothing, and its server answers the call itself, as before.
- The call ends at once. A call answered with a task ends its activity and gives the node its slot back: another job's call can run on that node while the server works.
- The job looks at the task from its timers. Each look is a short call of
tasks/get, with a token of the connection minted for the same function, which the edge checks as it checked the call. The job waits as long as the server asks between looks, at least 2 seconds and at most 60, and after ten looks twice as long each time, up to 60. The waits are timers in the job's history, so a node that restarts or a runtime that upgrades loses nothing: the job goes on looking. - A task that asks something waits for a person. A form the task asks (
input_required) becomes a question in the approvals inbox, with the form's fields; the answer goes back withtasks/update. Something only the caller's model or files could give (a sampling or a roots request), or a form whose schema is larger than 16 KiB, cancels the task, and the agent reads why: each look's answer stays in the job's history, and a server repeats its form until it is answered. - Its result is a call's result. A completed task's result goes through everything a call's answer does: the result's labels, the files it returns, the secrets scrubbed out of it, the usage it reports, the function's output schema. A failed task is an error the agent reads; one its server cancelled says why.
- Cancels reach the server. Cancelling the job, or a kill switch that stops the function, sends
tasks/cancel.
Every look is the call's
The looks carry the job's labels as they were when the call was made, which the edge checked it against, so a result that another call of the same turn brings in does not make the edge refuse a look of a call that already runs. A task's result is read only from the remote server that runs it: when the plugin changed meanwhile and the tool is no longer a remote tool on a connection, the call is not made again, and its outcome is unknown.
- The tool's time limit still holds. The tool's
timeoutbounds the time the job waits for the task, counted on the timers between looks; the time a question waits for a person is not counted, as no approval's wait is. A task still running at the limit is cancelled at its server, and the call ends as a call stopped at its limit does: whether it took effect is unknown. So does a task its server no longer knows. - One step in the trace. The job's trace keeps one step for the call, from the call to the look that read its result, and says the task's state and how many looks it took.
oz0 tenant tasks default --off stops it for every connection; jobs that run read the setting at their next call. The setting is part of the tenant's tool policy, so changing it takes a reason.