OAuth

The relay is its own OAuth authorization server. A connector such as ChatGPT registers itself, sends the person to a consent page, and trades the result for tokens, without anyone copying an agent key. This page documents the discovery documents, client registration, the browser flow (GET /authorize), the token endpoint and revocation. The flow for an agent that cannot open a browser is on Relay device flow. The examples use $RELAY, the relay's origin, as defined in Relay API.

Concepts#

The approval always happens on the user's Mac. The relay shows a web page or hands out a code, and Herald shows a banner that the user must approve. The web page alone never grants access.

FlowStarts atUse it when
BrowserGET /authorizeThe client can open a browser and receive a redirect.
DevicePOST /device_authorizationThe client is a sandboxed agent or a job with no browser.

Both flows end at POST /token and produce the same tokens. What an approved connector can do:

  • It sends notifications and reads receipts and replies. The only scope is notify.
  • It cannot send buttons, commands, scripts or any code.
  • It works until the user revokes it in Herald, in Settings > Cloud > Connector approvals.

Every OAuth route sends permissive CORS headers and answers OPTIONS with 204. Bodies are application/x-www-form-urlencoded, except POST /register, which takes JSON. OAuth errors are JSON in the shape {"error": "...", "error_description": "..."}.

Token lifetime#

An approved connector is a durable record in Herald. Its tokens do not follow a clock.

  • Access and refresh tokens never expire and are never rotated.
  • expires_in is 315360000 seconds, ten years, because OAuth clients expect the field.
  • A refresh returns a new access token and the same refresh token. Nothing the client holds is invalidated by using it, so a lost response can be retried.
  • Earlier access tokens stay valid. The relay keeps the newest 50 access tokens of a connector and drops older ones beyond that.
  • A retried code exchange (same code and PKCE verifier) returns tokens again. It never undoes the approval.
  • Revoking the connector in Herald ends all of its tokens and its event subscriptions at once.

Token shapes#

PrefixWhat it is
hrc_An authorization code, returned in the redirect.
hra_An access token. Send it as Authorization: Bearer.
hrr_A refresh token.
hrv_A device code, from the device flow.

Endpoints#

EndpointPurpose
GET /.well-known/oauth-protected-resourceDescribe the MCP endpoint and name the authorization server.
GET /.well-known/oauth-authorization-serverList the server's endpoints and capabilities.
POST /registerRegister a client.
GET /authorizeStart the browser flow.
POST /tokenExchange a grant for tokens.
POST /revokeRevoke a token.

GET /.well-known/oauth-protected-resource#

Describes the protected resource, the MCP endpoint, and names the relay as its authorization server. A client that gets a 401 from /mcp reads this document next (RFC 9728).

Request

No parameters.

Example requestshell
curl -s "$RELAY/.well-known/oauth-protected-resource" -H "User-Agent: Herald-Agent/1.0"
Example responseJSON
{
  "resource": "https://herald-relay.example.workers.dev/mcp",
  "authorization_servers": ["https://herald-relay.example.workers.dev"],
  "scopes_supported": ["notify"],
  "bearer_methods_supported": ["header"],
  "resource_name": "Herald relay"
}

Errors

StatusWhen
405The method is not GET (method_not_allowed).

Notes

  • /.well-known/oauth-protected-resource/mcp returns the same document.
  • The reply is cacheable for 300 seconds.

GET /.well-known/oauth-authorization-server#

Describes the authorization server: where its endpoints are and what it supports (RFC 8414). A client reads it to find /register, /authorize, /token, /device_authorization and /revoke.

Request

No parameters.

Example requestshell
curl -s "$RELAY/.well-known/oauth-authorization-server" -H "User-Agent: Herald-Agent/1.0"
Example responseJSON
{
  "issuer": "https://herald-relay.example.workers.dev",
  "authorization_endpoint": "https://herald-relay.example.workers.dev/authorize",
  "token_endpoint": "https://herald-relay.example.workers.dev/token",
  "registration_endpoint": "https://herald-relay.example.workers.dev/register",
  "device_authorization_endpoint": "https://herald-relay.example.workers.dev/device_authorization",
  "revocation_endpoint": "https://herald-relay.example.workers.dev/revoke",
  "scopes_supported": ["notify"],
  "response_types_supported": ["code"],
  "response_modes_supported": ["query"],
  "grant_types_supported": [
    "authorization_code",
    "refresh_token",
    "urn:ietf:params:oauth:grant-type:device_code"
  ],
  "code_challenge_methods_supported": ["S256"],
  "token_endpoint_auth_methods_supported": ["none", "client_secret_post", "client_secret_basic"],
  "revocation_endpoint_auth_methods_supported": ["none", "client_secret_post", "client_secret_basic"],
  "service_documentation": "https://github.com/ivg-design/herald/blob/main/docs/CLOUD.md"
}

Errors

StatusWhen
405The method is not GET (method_not_allowed).

Notes

  • /.well-known/openid-configuration returns the same document, for clients that look there.
  • The reply is cacheable for 300 seconds.

POST /register#

Registers an OAuth client dynamically (RFC 7591). A client registers once and keeps its client_id. A client that only uses the device flow has no redirect address to register.

Request

The body is JSON.

NameInTypeRequiredDescription
client_namebodystringoptionalThe name Herald shows in the approval banner, at most 60 characters. Control characters and < > are removed. Default An app.
redirect_urisbodyarray of stringsoptionalOne to 10 addresses. Required unless grant_types holds the device grant and nothing but refresh_token besides it.
grant_typesbodyarray of stringsoptionalAny of authorization_code, refresh_token and urn:ietf:params:oauth:grant-type:device_code. Default ["authorization_code"].
token_endpoint_auth_methodbodystringoptionalnone, client_secret_post or client_secret_basic. Default none.
response_typesbodyarray of stringsoptionalOnly code is accepted.

A redirect address must meet all of these rules:

  • It is https, or http on localhost, 127.0.0.1 or [::1], or a private-use scheme such as myapp://callback.
  • It has no fragment and no credentials.
  • It is at most 500 characters.
Example requestshell
curl -s -X POST "$RELAY/register" \
  -H "Content-Type: application/json" \
  -H "User-Agent: Herald-Agent/1.0" \
  -d '{"client_name":"My connector","redirect_uris":["https://agent.example.com/callback"]}'

Example response

The status is 201.

JSON
{
  "client_id": "hc_3fa91c0de27b45a6d1180123456789ab",
  "client_id_issued_at": 1790000000,
  "client_name": "My connector",
  "redirect_uris": ["https://agent.example.com/callback"],
  "token_endpoint_auth_method": "none",
  "grant_types": [
    "authorization_code",
    "refresh_token",
    "urn:ietf:params:oauth:grant-type:device_code"
  ],
  "response_types": ["code"],
  "scope": "notify"
}

Response fields

FieldTypeDescription
client_idstringThe client's id: hc_ and 32 hex characters.
client_id_issued_atintegerWhen it was issued, in Unix seconds.
client_namestringThe cleaned name.
redirect_urisarrayThe registered addresses.
token_endpoint_auth_methodstringThe method you asked for, or none.
grant_typesarrayAlways all three grant types, whatever you asked for.
response_typesarrayAlways ["code"].
scopestringnotify.
client_secretstring64 hex characters. Present only for client_secret_post and client_secret_basic. Shown once.
client_secret_expires_atinteger0: the secret does not expire. Present with client_secret.

Errors

StatusWhen
400The body is not JSON, or a grant type, response type or authentication method is not allowed (invalid_client_metadata).
400redirect_uris is missing, empty, has more than 10 entries, or holds an invalid address (invalid_redirect_uri).
429Too many clients registered in the last hour (rate_limited). The limit is 30.

Notes

  • Registration needs no credential.
  • The hourly limit is for the whole relay. The relay's owner can change it with the REGISTER_PER_HOUR variable.
  • The relay keeps the 200 most recently registered clients. A client that falls out of that list registers again.
  • A secret is stored only as a hash. Lose it and the client registers again.

GET /authorize#

Starts the browser flow. It shows the consent page, a web page that names the app, lists what it will be able to do, and waits for the user to approve on the Mac. Send the user's browser here.

Request

NameInTypeRequiredDescription
response_typequerystringrequiredcode.
client_idquerystringrequiredThe id from POST /register.
redirect_uriquerystringrequiredOne of the addresses the client registered, exactly.
code_challengequerystringrequiredThe PKCE challenge: 43 characters from A-Z a-z 0-9 - _.
code_challenge_methodquerystringrequiredS256. No other method is accepted.
statequerystringoptionalReturned unchanged on the redirect.
resourcequerystringoptionalThe MCP address, $RELAY/mcp. A trailing slash is ignored. Any other value is refused.
scopequerystringoptionalnotify.
devicequeryintegeroptionalWhich paired Mac receives the approval, counted from 1 in pairing order. Default: the Mac that is connected, else the one seen most recently.
Example requestshell
curl -s -G "$RELAY/authorize" \
  --data-urlencode "response_type=code" \
  --data-urlencode "client_id=hc_3fa91c0de27b45a6d1180123456789ab" \
  --data-urlencode "redirect_uri=https://agent.example.com/callback" \
  --data-urlencode "code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" \
  --data-urlencode "code_challenge_method=S256" \
  --data-urlencode "state=xyz" \
  -H "User-Agent: Herald-Agent/1.0"

Example response

The reply is an HTML page, not JSON. The page polls the relay every 2 seconds. When the user approves on the Mac, it sends the browser to redirect_uri with code and state.

text
HTTP/2 200
content-type: text/html; charset=utf-8
cache-control: no-store

<h1>My connector wants to connect to Herald</h1>

The redirect after approval looks like this:

text
https://agent.example.com/callback?code=hrc_...&state=xyz

Errors

StatusWhen
400The client is unknown, redirect_uri is not one it registered, or device is out of range. The page shows the error and the browser is never redirected.
302A later check failed. The browser goes to redirect_uri with error and error_description.
200The relay has no paired Mac. The page says to pair Herald first.
429Approvals are already waiting in Herald, or too many were requested. The page asks the person to answer them or wait.
502The relay could not start the approval.

The error values of the 302 redirect:

errorCause
unsupported_response_typeresponse_type is not code.
invalid_requestPKCE is missing: code_challenge with code_challenge_method=S256 is required.
invalid_targetresource is not the relay's MCP address.
invalid_scopeThe requested scope is anything other than notify.
access_deniedThe user denied the request, or entered five wrong approval codes.

Notes

  • Herald shows a banner on the Mac with Approve and Deny. The page also offers a 6-digit code, which Herald shows in Settings > Cloud > Connector approvals. Entering the right code approves. The fifth wrong code denies the request.
  • A request lasts 10 minutes. After approval, the client has 5 minutes to exchange the code.
  • The same client asking again takes over its open request, and the banner is updated with a new code. At most 3 requests wait on one Mac at once, and at most 12 are begun an hour.
  • Denying a request closes only that request. A connector that already works is untouched.
  • Approval creates one agent key for the connector, named after client_name, with notify scope. It appears in Settings > Cloud > Agent keys and can be revoked there. Approving the same client again takes the place of its earlier key.

The consent page uses these helper routes. They belong to the page and are not an API for clients.

RouteUsed byWhat it does
GET /authorize/status?rid=<id>The consent page, every 2 seconds.Returns {"status": "pending"} until the user decides, then the status with a redirect address.
POST /authorize/codeThe page's code form.Takes rid and code as a form. Approves on the right code, otherwise shows the tries left.
POST /authorize/denyThe page's Deny button.Takes rid as a form and denies the request.

POST /token#

Exchanges a grant for tokens. One endpoint serves all three grants, chosen by grant_type. A confidential client sends its client_secret in the form or in a Basic Authorization header. The device grant is described in Relay device flow.

Request

The body is a form. These fields are shared by every grant.

NameInTypeRequiredDescription
grant_typebodystringrequiredauthorization_code, refresh_token or urn:ietf:params:oauth:grant-type:device_code.
client_idbodystringrequiredThe id from POST /register.
client_secretbodystringoptionalThe secret of a confidential client.
resourcebodystringoptionalThe MCP address. It must match the one in the original request.

Fields of each grant:

GrantNameRequiredDescription
authorization_codecoderequiredThe hrc_ code from the redirect.
authorization_coderedirect_urirequiredThe same address used in /authorize.
authorization_codecode_verifierrequiredThe PKCE verifier: 43 to 128 characters from A-Z a-z 0-9 - . _ ~.
refresh_tokenrefresh_tokenrequiredThe hrr_ token from an earlier reply.
refresh_tokenscopeoptionalOnly notify.
device grantdevice_coderequiredThe hrv_ code from /device_authorization.

Example request

This exchanges an authorization code.

shell
curl -s -X POST "$RELAY/token" \
  -H "User-Agent: Herald-Agent/1.0" \
  -d grant_type=authorization_code \
  -d client_id=hc_3fa91c0de27b45a6d1180123456789ab \
  -d code=hrc_... \
  -d redirect_uri=https://agent.example.com/callback \
  -d code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
Example responseJSON
{
  "access_token": "hra_...",
  "token_type": "Bearer",
  "expires_in": 315360000,
  "refresh_token": "hrr_...",
  "scope": "notify"
}

Response fields

FieldTypeDescription
access_tokenstringThe bearer token, hra_.... Send it as Authorization: Bearer.
token_typestringBearer.
expires_ininteger315360000, ten years. The token does not expire. The field is for clients that require it.
refresh_tokenstringThe refresh token, hrr_.... A refresh returns the same one.
scopestringnotify.

Errors

A failed request is 400 with the OAuth error shape, except client authentication, which is 401.

ErrorWhen
invalid_grantA code, verifier, redirect address or refresh token is malformed, unknown, expired, for another client, or its connector was revoked. A wrong PKCE verifier is also invalid_grant.
invalid_targetresource does not match the authorization request.
invalid_scopeA refresh asked for a scope other than notify.
unsupported_grant_typegrant_type is not one of the three.
invalid_clientThe client is unknown or its secret is wrong. The status is 401.

The device grant adds authorization_pending, slow_down, access_denied and expired_token. They are listed on Relay device flow.

Notes

  • An access token works on every agent endpoint and on /mcp, exactly as an agent key does.
  • A retried exchange with the same code and verifier returns tokens again. A code that was never approved, or that is older than 5 minutes and was never exchanged, is invalid_grant.
  • The reply carries Cache-Control: no-store.
  • A client that authenticates with Basic and fails gets 401 with a WWW-Authenticate: Basic header.

POST /revoke#

Revokes a token the client holds (RFC 7009). A client calls it to sign out. It answers 200 with an empty object even when the token is unknown, so the reply never reveals whether a token existed.

Request

The body is a form.

NameInTypeRequiredDescription
tokenbodystringrequiredAn access token (hra_...) or a refresh token (hrr_...).
client_idbodystringrequiredThe id of the client the token was issued to.
client_secretbodystringoptionalThe secret of a confidential client.
Example requestshell
curl -s -X POST "$RELAY/revoke" \
  -H "User-Agent: Herald-Agent/1.0" \
  -d token=hrr_... \
  -d client_id=hc_3fa91c0de27b45a6d1180123456789ab
Example responseJSON
{}

Errors

StatusWhen
401The client is unknown or its secret is wrong (invalid_client).

Notes

  • Revoking a refresh token also revokes every access token issued with it. Revoking an access token revokes only that one.
  • A token that belongs to another client is ignored, and the reply is still 200.
  • To end a connector completely, revoke it in Herald. That also ends its other tokens and its event subscriptions.

Edit this page on GitHub

Esc
Getting started
Guides