Skip to main content

Connect an AI Agent with LangChain

Use this guide to connect an AI agent to ThunderID with LangChain, running under its own identity or on behalf of a user. In ThunderID an agent is a first-class identity that is also an OAuth client, so it can authenticate as itself or act with a user's delegated authority. You will register an agent once, then run it in whichever mode you pick.

Choose your language

Pick Python or TypeScript in any code block below. Your choice applies to every snippet on this page.

What You Will Learn
  • Register an AI agent in ThunderID
  • Authenticate the agent with its own credentials, or on behalf of a user
  • Give the agent a tool and run it with LangChain
  • See how the agent's identity changes what it can do
Prerequisites
  • About 15 minutes
  • Python 3.10+ or Node.js 18+ installed on your system
  • An LLM provider API key (e.g. a Google AI Studio key for Gemini)
  • A test user in ThunderID, for the on-behalf-of-a-user mode
  • Your preferred code editor
1

Run ThunderID

Start a local ThunderID instance. Pick the method that works best for you:

$npx thunderid

Requires Node.js 18+

Full install guide →

Once it is running, the Console is available at https://localhost:8090/console.

2

Register an AI Agent

An agent in ThunderID is a first-class identity that is also an OAuth client. Its Client ID and Client Secret are the OAuth client_id and client_secret it authenticates with, and are separate from the Agent ID that identifies the agent itself.

Open the Console at https://localhost:8090/console, navigate to Agents, and click Add Agent:

  1. Enter an Agent name (e.g. Enrollment Agent), or pick one of the generated suggestions.

  2. Fill in the agent attributes the schema asks for. With the default schema these are:

    • Model Provider (required): the LLM vendor, e.g. gemini.
    • Model (required): the model name, e.g. gemini-2.5-flash.
    • Function (optional): what the agent is for, e.g. assistant.

    These are descriptive metadata. If your organization customized the agent schema, you will be prompted for those fields instead.

  3. Select an Owner, the user accountable for this agent.

  4. Click Create agent.

  5. Copy the Client Secret from the dialog that appears. It is shown only once. The Client ID is available anytime on the agent's General tab, where you can also regenerate the secret if you lose it.

3

Set Up Environment Variables

Create a .env file in your project with the agent credentials you just copied and your LLM provider API key. The same file works for both modes:

.env
# ThunderID (local, self-signed cert)
THUNDERID_BASE_URL=https://localhost:8090

# The agent's OAuth client credentials (from the ThunderID Console)
AGENT_CLIENT_ID=<your-agent-client-id>
AGENT_SECRET=<your-agent-client-secret>

# Used when the agent acts on behalf of a user. Must match a redirect URI
# registered on the agent.
REDIRECT_URI=http://localhost:6274/callback

# LLM provider API key (e.g. a Google AI Studio key for Gemini)
LLM_API_KEY=<your-llm-api-key>

# Optional. Defaults to Gemini 2.5 Flash; set MODEL_NAME to override, e.g.
# google_genai:gemini-2.5-flash on Python, google-genai:gemini-2.5-flash on Node.
# MODEL_NAME=google_genai:gemini-2.5-flash
4

Install the Libraries

Create and activate a virtual environment:

python3 -m venv .venv
source .venv/bin/activate

Install the core dependencies:

pip install requests python-dotenv pydantic langchain langchain-core langgraph

Add the LangChain integration for your LLM provider. For Google Gemini:

pip install langchain-google-genai

Using a different provider? Install its integration instead, e.g. langchain-anthropic or langchain-openai.

5

Choose How the Agent Behaves

Your agent can act with its own identity or with a user's delegated authority. Choose a mode below to see the setup and code for it.

No extra agent configuration is needed. An agent can authenticate with its own credentials out of the box.

6

Build and Run the Agent

ThunderID
User
AI
Agent
🛠️ Tool
Validate
List modules
1asks
2get token
3access token
4calls tool

You will build a small student enrollment agent with LangChain that reads the student's enrolled modules. Because an agent is an OAuth client, it signs in with the standard client-credentials grant, just like any machine-to-machine client. This is the agent's subject posture: a low-risk, read-only lookup it can do on its own, with no user in the loop.

Make sure your .env has the agent's Client ID and Client Secret set as AGENT_CLIENT_ID and AGENT_SECRET (copy them from the agent's General tab in the Console), plus your LLM_API_KEY.

The core is a single request to the token endpoint, authenticating with the agent's ID and secret:

def get_agent_token() -> str:
"""Fetch a brand new client_credentials token. No caching, no reuse."""
data = {"grant_type": "client_credentials"}
if SCOPE:
data["scope"] = SCOPE

resp = requests.post(
TOKEN_ENDPOINT,
data=data,
auth=(AGENT_CLIENT_ID, AGENT_SECRET),
headers={"Accept": "application/json"},
)
resp.raise_for_status()
return resp.json()["access_token"]

A middleware fetches a fresh token before every tool call and hands it to the tool through a context variable, so the my_modules tool stays free of auth plumbing.

Complete agent_own_token.py
agent_own_token.py
import os
import asyncio
import contextvars

import requests
from dotenv import load_dotenv
from pydantic import BaseModel

from langchain_core.tools import StructuredTool
from langchain_core.messages import HumanMessage
from langchain.chat_models import init_chat_model
from langchain.agents import create_agent
from langchain.agents.middleware import AgentMiddleware


# --- Configuration (loaded from .env) ---
load_dotenv()

BASE_URL = os.environ.get("THUNDERID_BASE_URL", "https://localhost:8090")
AGENT_CLIENT_ID = os.environ["AGENT_CLIENT_ID"] # the agent's OAuth client_id
AGENT_SECRET = os.environ["AGENT_SECRET"] # the agent's OAuth client_secret
SCOPE = os.environ.get("AGENT_SCOPE", "") # optional, space-separated
MODEL_NAME = os.environ.get("MODEL_NAME", "google_genai:gemini-2.5-flash")
LLM_API_KEY = os.environ.get("LLM_API_KEY", "")

TOKEN_ENDPOINT = f"{BASE_URL}/oauth2/token"


# --- ThunderID agent authentication (client_credentials) ---
def get_agent_token() -> str:
"""Fetch a brand new client_credentials token. No caching, no reuse."""
data = {"grant_type": "client_credentials"}
if SCOPE:
data["scope"] = SCOPE

resp = requests.post(
TOKEN_ENDPOINT,
data=data,
auth=(AGENT_CLIENT_ID, AGENT_SECRET),
headers={"Accept": "application/json"},
)
resp.raise_for_status()
return resp.json()["access_token"]


# --- In-memory data ---
enrolled = ["CS101", "MATH110"]


# --- Token handoff: contextvar, not tool args ---
_agent_token_ctx: contextvars.ContextVar[str | None] = contextvars.ContextVar(
"agent_token", default=None
)


# --- Middleware: fetch a fresh agent token right before each tool call ---
class AgentTokenMiddleware(AgentMiddleware):

async def awrap_tool_call(self, request, handler):
token = get_agent_token() # fetched fresh, every single tool call
reset = _agent_token_ctx.set(token)
try:
return await handler(request)
finally:
_agent_token_ctx.reset(reset)


# --- Tools ---
class MyModulesSchema(BaseModel):
"""No arguments; lists the student's enrolled modules."""
pass


async def my_modules_fn() -> str:
"""List the student's enrolled modules."""
token = _agent_token_ctx.get()
if not token:
return "No credentials available; cannot read modules."
if not enrolled:
return "You are not enrolled in any modules."
return "Enrolled modules: " + ", ".join(enrolled)


my_modules = StructuredTool(
name="my_modules",
description="List the student's currently enrolled modules.",
args_schema=MyModulesSchema,
coroutine=my_modules_fn,
)

tools = [my_modules]


# --- Agent ---
def get_prompt() -> str:
return (
"You help a student view their enrolled modules. Use my_modules to list "
"them. Call exactly one tool per request and report the tool's result "
"plainly, without adding claims the tool did not return."
)


llm = init_chat_model(MODEL_NAME, api_key=LLM_API_KEY)

agent = create_agent(
llm,
tools=tools,
system_prompt=get_prompt(),
middleware=[AgentTokenMiddleware()],
)


# --- Run ---
async def run_turn(prompt: str):
print(f"\n> {prompt}")
result = await agent.ainvoke({"messages": [HumanMessage(content=prompt)]})
print("Agent:", result["messages"][-1].content)


async def main():
await run_turn("What modules am I enrolled in?")


if __name__ == "__main__":
asyncio.run(main())

ThunderID serves HTTPS with a self-signed certificate, so trust it once in the shell you run from. In production, point REQUESTS_CA_BUNDLE at your real CA bundle instead, with no code change.

openssl s_client -connect localhost:8090 -servername localhost </dev/null 2>/dev/null \
| openssl x509 > thunderid.pem

export REQUESTS_CA_BUNDLE="$PWD/thunderid.pem"

Run it:

python agent_own_token.py

The agent fetches its own token and runs one turn. You should see output like:

> What modules am I enrolled in?
Agent: Enrolled modules: CS101, MATH110
Success

The agent authenticated as itself and acted on its own behalf, under its own identity, with no user involved.

What's Next

Explore with AI

ThunderID LogoThunderID Logo

Product

DocsAPIsSDKs
© Copyright Linux Foundation Europe.For web site terms of use, trademark policy and other project policies please see https://linuxfoundation.eu/en/policies.