Authentication
The SDK never creates, stores, or refreshes credentials itself — it only formats whatever token or key your application already has into the right header. Which pattern you need depends on which side you're on, and whether your credential is static for the life of a Client or needs to change mid-session.
Calling an agent or the gateway: AuthProvider
Every client (Client, GatewayClient, AgentDNSClient) accepts an auth argument implementing AuthProvider. It's read once, at construction, and formatted into every request's headers from then on. Two implementations are built in.
Bearer tokens — for dynamic, short-lived tokens (OAuth/OIDC access tokens, NovaOS IAM tokens for the Execution Gateway). Never store these in environment variables; your application supplies them at call time.
from novaos_client_sdk import Client, BearerTokenAuth
token = my_app.get_access_token() # however your app obtains one
auth = BearerTokenAuth(token=token)
async with Client(base_url="http://my-agent.example.internal:8000", auth=auth) as client:
...
API keys — for static, service-to-service credentials. These are safe to source from the environment:
from novaos_client_sdk import APIKeyAuth, get_api_key_from_env
# Explicit
auth = APIKeyAuth(api_key="my-key", header_name="X-API-Key")
# From the ACP_API_KEY environment variable
auth = get_api_key_from_env()
A custom provider — implement the one-method interface for anything else (mTLS-derived headers, a signed-request scheme, ...):
from novaos_client_sdk import AuthProvider
class CustomAuth(AuthProvider):
def get_headers(self) -> dict[str, str]:
return {"X-Custom-Auth": my_signing_function()}
A credential that changes mid-session: AuthInterceptor
auth= above is read once and can't hand back a different credential later — fine for a static API key, not for a client-credentials OAuth2 token that's about to expire. Client also accepts interceptors=, a list of ClientCallInterceptor instances that run around every call. AuthInterceptor is the built-in one for credentials, and it resolves a header per call, in this order:
- If a
CredentialServicewas configured,AuthInterceptorpicks which of the agent's Agent CardsecuritySchemesthis call should use (matching the card's declaredsecurityRequirements, or the first declared scheme if there's nothing to match against) and callsawait credential_service.get_credentials(scheme_name, context). - If that returns a credential, it's formatted into a header using the matched scheme's shape — an
apiKeySecuritySchemebecomes its declared header name, everything else (including NovaOS's own execution-token scheme and OAuth2) becomesAuthorization: Bearer <credential>. A service that already knows its own header shape can return a ready-made{header: value}mapping instead, used unchanged. - Otherwise — no
CredentialServiceconfigured, or one configured that returned nothing — it falls back to the constructor'sAuthProvider, so a caller who supplies onlyauth=sees no change at all.
from novaos_client_sdk import Client, AuthInterceptor, CredentialService
class LogtoTokenService(CredentialService):
def __init__(self, logto_client) -> None:
self._logto_client = logto_client
async def get_credentials(self, scheme_name, context):
# Your own refresh logic decides whether the cached token is still
# good; the SDK never sees that logic, only the result.
return await self._logto_client.get_access_token()
interceptor = AuthInterceptor(credential_service=LogtoTokenService(logto_client))
async with Client(base_url="http://my-agent.example.internal:8000", interceptors=[interceptor]) as client:
...
The application still owns obtaining the credential — CredentialService is only the hook it refreshes through. For the common case of a credential your application already has per-session (its own OAuth2 flow) with nothing to refresh, InMemoryContextCredentialStore is a ready-made lookup table keyed by context.state["session_id"] and scheme name — populate it with set_credentials(session_id, scheme_name, credential) instead of writing a CredentialService.
Inside an agent: the execution context
An agent running on the NovaOS platform sits behind an auth-proxy sidecar that injects the caller's identity and a short-lived execution token as headers on every request — you don't mint or manage these yourself. AgentExecutionContext (the ctx your skill receives) parses them for you:
from novaos_client_sdk import AgentExecutionContext
async def my_skill(ctx: AgentExecutionContext, input_text: str):
print(f"user={ctx.user_id} tenant={ctx.tenant_id} run={ctx.run_id}")
# Propagate the execution token to a downstream call your skill makes directly
outbound_headers = ctx.to_execution_context().to_auth_headers()
# {"Authorization": "Bearer <execution-token>"}
ctx.tools.* (see Platform tools) already attach this token automatically — you only need to_auth_headers() for calls the SDK doesn't make on your behalf.
Running the agent locally, outside the sidecar? There's no token to inject, so calls that require one raise MissingExecutionTokenError. Set the NOVAOS_EXECUTION_TOKEN environment variable to a test value, or pass headers explicitly, to unblock local development.
Per-run secrets: sealed delivery
The platform can also hand your agent secrets scoped to one run — an SMTP password, a third-party API key — without ever putting the plaintext on the wire in the clear. Its Temporal worker seals each value to your pod's own key before the request leaves the control plane; the sealed payload rides inside the A2A user-context/v1 extension and is opened only inside your process, past the auth-proxy sidecar. You don't need to know any of that to use it: call ctx.get_secret("SECRET_NAME") (optionally with a default for one that may not be delivered) from any skill, and a SecretNotDeliveredError tells you if nothing arrived under that name. This is entirely platform-driven — nothing in your agent code chooses what gets sealed or when.
Verifying tokens: JWKSTokenVerifier
If your agent needs to verify an execution token itself (rather than trusting the sidecar), JWKSTokenVerifier fetches and caches signing keys from the gateway's JWKS endpoint. Requires the crypto extra:
import os
from novaos_client_sdk import JWKSTokenVerifier
verifier = JWKSTokenVerifier(
gateway_url=os.environ["GATEWAY_URL"],
issuer=os.environ["NOVAOS_TOKEN_ISSUER"], # required — see below
)
token = request.headers["Authorization"].removeprefix("Bearer ")
claims = verifier.verify(token)
print(claims["user_id"], claims["tenant_id"], claims["run_id"])
issuer is required, deliberately with no default: without it, a validly signed token from an unrelated tenant sharing the same JWKS endpoint would still pass verification. Pass the issuer your gateway actually issues tokens for.
If you've restricted outbound hosts with NOVAOS_ALLOWED_HOSTS (a comma-separated list of allowed hostname suffixes), gateway_url is checked against it before any request is made.
Where these compose
| You are... | Use |
|---|---|
| Calling another agent, with a static credential | Client(auth=BearerTokenAuth(...)) or a service APIKeyAuth |
| Calling another agent, with a credential that refreshes mid-session | Client(interceptors=[AuthInterceptor(credential_service=...)]) |
| A frontend driving the Execution Gateway | GatewayClient(auth=BearerTokenAuth(token=novaos_iam_token)) |
| An agent skill making its own downstream call | ctx.to_execution_context().to_auth_headers() |
| An agent skill reading a platform-delivered secret | ctx.get_secret("NAME") |
| An agent verifying a caller's token itself | JWKSTokenVerifier |
See Error handling for how auth failures surface, and the A2A primer for what an Agent Card's securitySchemes actually declare.