temperDocs
Menu

Concepts

Credentials

How end-user OAuth connections and your own API keys reach the agent as stand-in tokens, while the real values never enter the VM.

An agent that can be talked into anything by a web page or an email should not hold real tokens. Temper keeps every real credential outside the VM. The agent only ever sees stand-in tokens (tmpr_...). When a request carrying a stand-in leaves the VM for a host that credential is bound to, a credential gateway on the host checks it and swaps it for the real value. Anywhere else, a stand-in is worthless.

There are two kinds of credentials:

  • Connections: your end user's own accounts (Gmail, Google Calendar, Slack), authorized through OAuth.
  • Secrets: API keys and tokens that you own, such as your model provider key or an internal service token.

How a stand-in is swapped

  1. Code in the VM sends a request with a stand-in token in a header. All traffic leaves through the environment's egress proxy.
  2. For hosts that a credential is bound to, the proxy does not open the connection itself. It hands the encrypted connection to the credential gateway on the host, outside the VM.
  3. The gateway accepts a stand-in only if it belongs to this VM's environment, sits in the expected header, and the request goes to one of the credential's hosts and allowed paths. Otherwise the whole request is refused, and the stand-in is never forwarded.
  4. The gateway checks your approval policy, replaces the stand-in with the real value, and sends the request. Every request is written to the audit log with the credential's name, never its value.

Real tokens live only in Temper's encrypted credential vault and in the gateway's memory. They are never written into the VM, so no process in the VM can read them, not even one that escapes the agent's sandbox.

Connections (end-user OAuth)

A connection is one end user's authorization for one provider. Temper stores the tokens encrypted, refreshes them, and never returns them in any API response.

ProviderConnectorsTools in the VM
googlegmail, calendartemper-gmail, temper-gcal
slackslacktemper-slack

Starting an authorization

Your backend asks Temper for an authorization link and sends the end user there. The connectors must all belong to one provider.

const link = await temper.connections.authorize({
  end_user_id: "alice",
  connectors: ["gmail", "calendar"],
  return_url: "https://app.example.com/connected",
});
// Redirect the end user to link.url (valid until link.expires_at, single use).
link = temper.connections.authorize(
    end_user_id="alice",
    connectors=["gmail", "calendar"],
    return_url="https://app.example.com/connected",
)
# Redirect the end user to link.url (valid until link.expires_at, single use).
link, err := client.Connections.Authorize(ctx, temper.AuthorizeConnectionParams{
	EndUserID:  "alice",
	Connectors: []string{"gmail", "calendar"},
	ReturnURL:  "https://app.example.com/connected",
})
// Redirect the end user to link.URL (valid until link.ExpiresAt, single use).

The link is valid for 10 minutes and can be used once. After the user agrees on the provider's consent page, the provider sends them back to Temper, which exchanges the code for tokens and then redirects to your return_url with the result added to the query string:

  • success: ?status=ok&connection_id=conn_...
  • failure (the user declined, or the exchange failed): ?status=error&error=access_denied

Temper also sends a connection.created webhook on success.

An end user has at most one active connection per provider. Authorizing again replaces the old connection, and the connection.created event names the one it replaced in replaced.

The consent page shows your own app's name once you register your Google or Slack OAuth app with setOAuthClient. Until then Temper uses its own test app, which only test users can authorize. If neither is available, authorize returns 409 no_oauth_client. See Use your own OAuth app.

Using a connection in the VM

Once a user is connected, the stand-in tokens for their environment are created automatically. Built-in connectors work differently from secrets: the agent never gets their stand-ins. It calls the command-line tools in the VM, which pass the request to a separate worker outside the agent's sandbox. The worker makes the call through the credential gateway, and filters out one-time codes, password-reset links and magic sign-in links from emails and calendar entries before the agent sees them.

bash
# Inside the VM (the agent's code)
temper-gmail search "from:bob newer_than:7d" --max 5
temper-gmail send --to bob@example.com --subject "Thursday" --body "Moved to Thursday."
temper-gcal list --from 2026-11-02T00:00:00Z --to 2026-11-09T00:00:00Z
temper-slack post C0123456 "Build is green"

The tools print JSON. They exit with code 3 when the user hasn't connected that provider and 4 when an approval was denied. Sending mail, posting to Slack and deleting go through approvals.

Each connector's stand-in is limited to its own API: gmail only works on gmail.googleapis.com under /gmail/v1/, calendar only on www.googleapis.com under /calendar/v3/, and slack only on slack.com under /api/. A calendar token can't read mail, even though both come from the same Google authorization.

Listing and revoking

list returns an end user's connections, newest first (filter with end_user_id; revoked ones are left out unless you ask for them). revoke revokes the token at the provider where possible, deletes Temper's copy, invalidates the stand-ins in every environment, and sends connection.revoked.

A connection's status is active, revoked or error. error means the provider rejected a token refresh, usually because the user removed access on the provider's side. Temper sends connection.error, and the user needs to authorize again.

See the connections API.

Secrets (your own keys)

A secret is a value you own, such as OPENROUTER_API_KEY, bound to the hosts it may be sent to. You set it once, and the agent uses its stand-in like a normal key.

FieldRequiredMeaning
valueyesThe real value. Up to 8,192 bytes, no newlines. Never returned by any endpoint.
hostsyes1 to 20 exact host names (lowercase, no scheme, port or wildcard). The value is only ever sent to these.
pathsnoUp to 20 path prefixes, each starting with /. Empty means any path.
headernoThe header that carries the value. Defaults to authorization. Can't be host, cookie or content-length.
prefixnoPut before the value in the header. Defaults to Bearer for authorization, and to nothing for any other header.

Names look like environment variables: an uppercase letter followed by up to 63 uppercase letters, digits or underscores.

Developer-level and environment-level secrets

  • Developer-level secrets (set) exist in every one of your environments.
  • Environment-level secrets (setForEnvironment, Python set_for_environment, Go SetForEnvironment) exist in one environment, and override a developer-level secret with the same name. Use them for per-user keys.

When you delete an environment-level secret that shadowed a developer-level one, the environment goes back to the developer-level value.

Setting a secret

const secret = await temper.secrets.set("OPENROUTER_API_KEY", {
  value: process.env.OPENROUTER_API_KEY!,
  hosts: ["openrouter.ai"],
  paths: ["/api/v1/"],
});

// A per-user key in a custom header, with no prefix:
await temper.secrets.setForEnvironment(env.id, "ACME_API_KEY", {
  value: userKey,
  hosts: ["api.acme.example"],
  header: "x-api-key",
});
import os

secret = temper.secrets.set(
    "OPENROUTER_API_KEY",
    value=os.environ["OPENROUTER_API_KEY"],
    hosts=["openrouter.ai"],
    paths=["/api/v1/"],
)

# A per-user key in a custom header, with no prefix:
temper.secrets.set_for_environment(
    env.id, "ACME_API_KEY", value=user_key, hosts=["api.acme.example"], header="x-api-key"
)
secret, err := client.Secrets.Set(ctx, "OPENROUTER_API_KEY", temper.PutSecretParams{
	Value: os.Getenv("OPENROUTER_API_KEY"),
	Hosts: []string{"openrouter.ai"},
	Paths: []string{"/api/v1/"},
})
if err != nil {
	log.Fatal(err)
}

// A per-user key in a custom header, with no prefix:
_, err = client.Secrets.SetForEnvironment(ctx, env.ID, "ACME_API_KEY", temper.PutSecretParams{
	Value:  userKey,
	Hosts:  []string{"api.acme.example"},
	Header: "x-api-key",
})

Setting a secret that already exists replaces it. The response describes the secret (hosts, paths, header, prefix, last_used_at) but never includes the value. In Go, Prefix is a pointer: leave it nil for the default, or pass temper.Ptr("") to send the value with no prefix in the authorization header.

Using a secret in the VM

Inside the VM, the agent reads the stand-in from /run/temper/secrets/NAME and uses it exactly as it would use the real key:

bash
# Inside the VM (the agent's code)
export OPENROUTER_API_KEY=$(cat /run/temper/secrets/OPENROUTER_API_KEY)
curl -s https://openrouter.ai/api/v1/models -H "Authorization: Bearer $OPENROUTER_API_KEY"

On a request to openrouter.ai under /api/v1/, the gateway replaces the Bearer tmpr_... header with the real key. Sent anywhere else, the stand-in is just a meaningless string, so a leaked stand-in can't be used outside the environment.

When changes take effect

  • A secret's hosts are added to the environment's egress allowlist automatically.
  • Deleting a secret or changing its hosts or paths takes effect immediately, with no VM restart.
  • A new value takes effect within a few minutes. The stand-in stays the same, so the agent doesn't need to reread it.
  • A new secret's stand-in file appears in running VMs shortly after you create it.

Approvals for secrets

Requests that use a secret are checked against your approval policy like any connector. The connector name is secret: followed by the secret's name, for example secret:OPENROUTER_API_KEY. GET, HEAD and OPTIONS requests count as read and everything else counts as write.

Infrastructure secrets

Some secrets aren't about acting for the user at all: a token your agent uses to reach your own backend, or a key for a service that only your infrastructure talks to. Store those with kind: "infrastructure". An infrastructure secret is still bound to its hosts and paths, the agent still only sees a stand-in, and every use is still audited, but requests that use it skip the action policy, so you don't need an allow rule. If a request also carries a standard credential, the policy is checked for that one.

await temper.secrets.setForEnvironment(envId, "BACKEND_TOKEN", {
  value: process.env.BACKEND_TOKEN!,
  hosts: ["api.yourapp.example"],
  kind: "infrastructure",
});
temper.secrets.set_for_environment(
    env_id, "BACKEND_TOKEN", value=os.environ["BACKEND_TOKEN"], hosts=["api.yourapp.example"], kind="infrastructure"
)
_, err := client.Secrets.SetForEnvironment(ctx, envID, "BACKEND_TOKEN", temper.PutSecretParams{
	Value: os.Getenv("BACKEND_TOKEN"),
	Hosts: []string{"api.yourapp.example"},
	Kind:  "infrastructure",
})

Keep anything that acts for the end user, or that you want to gate with approvals, as a standard secret. To see how a request would be decided, use the policy check.

See the secrets API and Use your own model API key.