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
- Project secrets hold the client ID and client secret, encrypted at rest. They are exposed to your code agent’s Python as the injected
secretsnamespace. - The code agent exchanges the credentials for an access token and calls your protected API with it.
- 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. CreateAUTH0_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:
UPPER_SNAKE_CASE (^[A-Z][A-Z0-9_]*$). Values are encrypted and never returned by any API.
2. Write the code agent
Create a code agent with a single inputmessage 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:
- Entry point:
class Codewith__call__(no constructor arguments). secretsis injected into the module globals. It is not the Python stdlibsecretsmodule. Do notimport secrets; that would shadow the injected namespace.os.environis 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.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 nolangwatch.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_SESSIONandSUPPORT_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.
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 answersTrue 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}.