Skip to main content

Static credential or token exchange

Many production agents sit behind an API that requires authentication. A static credential (bearer / api_key / basic) is enough when the API accepts a fixed token, and an HTTP target reads that credential from your project’s secrets. An OAuth2 client-credentials flow (Auth0 “machine-to-machine” is the usual example) needs more, because a token must be exchanged first. Start with the HTTP target below. When the credential must be exchanged, use a custom code agent. It is a few lines of Python that fetch the token and call your API with it. The credentials stay in your project’s encrypted secret store, never in the agent’s stored source.

Referencing project secrets from an HTTP target

An HTTP target reads a project secret as {{ secrets.NAME }}. You then do not type the API key into the target’s configuration, where all users who can open the target can read it. Store the credential in Settings → Secrets, then reference it by name. References resolve in these fields: A reference in the body template is sent as written. A reference to a secret name the project does not have also stays as written, and does not become an empty string. The request then fails as a missing secret, and not as an unauthenticated call. Substitution runs when each request is built. A secret that you rotate applies on the next turn, and you do not change the target. The run removes resolved values from everything it shows back. An error from a rejected request gives the failure and shows [redacted] where the credential was. Auth fields typed in directly still work exactly as typed. A value with no {{ secrets.NAME }} in it is sent unchanged. Code agents and workflow targets read the same secrets.NAME namespace. A value that is not a credential, for example a tenant or a fixture id, goes in a run parameter. A run parameter is read as {{ params.NAME }}. You can change it when the run starts, and it is recorded on the run. Also check: Secret parameters, for a credential that changes per run. You supply the value when the run starts, the target reads it as {{ secrets.NAME }}, and the run records the name without the value. A run value takes the place of a project secret of the same name, for that run only.

How it fits together

  1. Project secrets hold the client ID and client secret, encrypted at rest. They are exposed to your code agent’s Python as the injected secrets namespace.
  2. The code agent exchanges the credentials for an access token and calls your protected API with it.
  3. A scenario drives the agent like a real user and judges the answers. The agent only passes if it got through the auth wall.

1. Store the credentials as project secrets

In Settings → Secrets, create the credentials and the endpoint coordinates. Keeping the endpoints in secrets too means the whole agent is configurable without touching its code or any API. Create AUTH0_CLIENT_SECRET through the Settings → Secrets UI, not on a command line: a secret passed as a CLI argument lands in your shell history and the process listing, and can end up in CI logs. The non-sensitive coordinates are fine to create via the CLI:
Secret names must be UPPER_SNAKE_CASE (^[A-Z][A-Z0-9_]*$). Values are encrypted and never returned by any API.
Never paste the client secret into the agent’s Python. The stored code is readable by anyone with project access; the secrets namespace exists so the credential is stored encrypted at rest and never persisted in the agent’s source. At run time it is injected into the execution’s memory and sent only to your token endpoint.

2. Write the code agent

Create a code agent with a single input message and a single declared output output: the declared output key must match the key the Python returns, or the run fails with missing_output. Use this Python, the reference implementation, exercised continuously against the real code-agent runtime:
Things the sandbox enforces, in the order people trip on them:
  • Entry point: class Code with __call__ (no constructor arguments).
  • secrets is injected into the module globals. It is not the Python stdlib secrets module. Do not import secrets; that would shadow the injected namespace. os.environ is not populated in the sandbox.
  • Return every declared output key, or the run fails with missing_output.
  • Available packages: requests, httpx, pydantic, langwatch.
  • Budget: the token fetch plus your API call must finish inside the runner’s wall-clock limit (60s by default). Keep explicit timeouts on both requests.
  • Failure behavior: raise_for_status() puts the URL and status code in the error, never the request body, so a rejected credential fails loudly without exposing the secret.

3. Map the agent’s input for scenarios

Because everything else rides the secrets namespace, the agent has a single input, the conversation message, and needs exactly one mapping: message from the scenario’s input. That mapping is creatable in the agent editor today.
Keeping the endpoint coordinates in secrets is deliberate: the alternative, static value mappings on extra inputs, currently cannot be created from the editor UI, only via the agents API. UI support for static value mappings is tracked in #6371.

4. Run a scenario against it

Create a scenario whose criteria can only pass if the agent got through the auth wall, for example facts that only your protected API returns. File it into a test suite, and run the test suite against the agent. The simulation transcript shows the agent’s answers; the run fails if the exchange breaks.
That answer exists only behind the auth wall, so the scenario passing is the proof that authentication works.

Reusing one login across the rows of a run

Rows are isolated and run in parallel, so by default each row logs in on its own. You can use the agent cache to store the login credential after the first row and read it back on every row that follows, so the run authenticates once instead of once per row. The cache is encrypted at rest and entries expire on their own. The platform puts a short-lived credential scoped to the cache inside the sandbox, shared by the runs of the project, so no langwatch.setup() call or LangWatch secret is required. This code is executed on every commit against the real runtime:
shared_session_code_agent.py
Requires langwatch 1.3.0 or later.

Tuning the TTL

SESSION_TTL_SECONDS is the session lifetime the target system returns. REFRESH_MARGIN_SECONDS is subtracted from it so a stored session is never about to expire when a row reads it. Set the margin between the duration of your slowest row and SESSION_TTL_SECONDS. The agent also treats a 401 as an expired session: it logs in again and retries once, so an early expiration costs one extra login instead of failing the run.

Cache limits

Behavior

  • Each run logs in only once. Every later row reads the stored session from the cache.
  • When rows start at the same time, one of them takes the claim and logs in. The rest wait and read the session it stores.
  • Entries belong to the project, not to a single run. A second run reuses the session the first run stored. If two concurrent runs write the same entry name, the last write wins. Use distinct names per agent (for example BILLING_SESSION and SUPPORT_SESSION) to avoid collisions.
  • If a read fails, the row treats it as a miss and logs in. If a write fails, the row still succeeds; only the next row has to log in again.
Source: shared_session_code_agent.py.

One login when rows start together

The rows that start at the same moment all read an empty cache, so without a claim they all log in. The agent above takes a claim first. A claim writes only when the project does not hold that name yet, and answers True for the row that took it and False for the rows that did not, so one row logs in and the rest wait for the session it stores. LOGIN_SECONDS is the one number to set: how long a row holds the claim. Put it above the time your login takes. That single number also ends the wait. A row that takes the claim and then stops does not hold the others for good, because the claim expires and the next pass of a waiting row takes the name. Set it below your login time and the claim stops arbitrating: the name comes free while the first row is still logging in, so a second row takes it and logs in as well, and the last session written is the one the rows that follow read. Both sessions are valid, so the cost is extra logins, which is the result with no claim at all. The same applies when the cache itself cannot answer. The claim uses the entry’s own name with _CLAIM after it, so there is no second name to pick. It has to be a separate entry: the claim is taken before the login, and the session can only be stored after it.

Cache API

get returns default on a miss. claim returns True when it stored the value and False when another row already holds the name. Every other failure raises, with the rejected field in the message: 400 (validation_error: ttl_seconds: ttl_seconds must be at least 5). Pass a str, a dict or a list as the value. The SDK stores a dict or list as JSON and get returns it parsed, so JSON types apply: a tuple comes back as a list, a non-string key as a string, and a value JSON cannot encode (nan, inf, a set, a datetime) raises TypeError before the call. Text that is a JSON object or array also comes back parsed, including an entry written over REST or by an older SDK. Any other text comes back unchanged. Also available over REST at /api/agent-cache/{name}.

Troubleshooting

Last modified on August 28, 2026