Connect an AI Agent with LangChain
Use this guide to connect a LangChain agent to ThunderID using its own identity or acting on behalf of a user. Choose a mode to see its setup, code, and expected result.
Your agent will list the available courses using its own permissions. No student signs in.
Your agent will enroll you in a course after you sign in and consent. It uses your delegated permissions.
Prerequisites
- Python 3.12 and pip, or Node.js 24 and npm.
- A Google AI Studio API key with access to your Gemini model.
- A browser on the same computer as the sample. Sign-in uses local port
6274.
Select Python or TypeScript in a code block. Your choice applies throughout the page. Each example runs one request with one available tool, then exits.
Run ThunderID
Start a local instance, or use one you already have:
Requires Node.js 18+
Full install guide →Open the Console at https://localhost:8090/console.
Register an Agent
- In the Console, open Agents and click Add agent.
- Name it
Course Agent. Select Gemini as the Model Provider, entergemini-flash-lite-latestas the Model, and select an Owner. Complete the wizard to create the agent. - Copy the Client Secret when shown, then the Client ID from Overview → Agent details. Keep them for
.env; the secret appears only once.
Configure Access
In Resource servers, create an API resource server named Course Catalog with Identifier https://courses.example.com and permission delimiter :. On Resources, create Courses with handle courses.
Create the following action under Courses. In Roles, create the role with that permission, then assign it on Assignments → Add assignment:
| Action name | Handle | Permission | Role | Assign to |
|---|---|---|---|---|
| List courses | read | courses:read | Course Reader | Agent → Course Agent |
Use an existing non-admin user as the student, or create one in Users with a username and password. Create the following action under Courses. In Roles, create the role with that permission, then assign it on Assignments → Add assignment:
| Action name | Handle | Permission | Role | Assign to |
|---|---|---|---|---|
| Enroll in a course | enroll | courses:enroll | Student | User → your test student |
Enable delegated access and consent:
- In Agents → Course Agent → Advanced, turn on Delegated mode. Keep Require PKCE on, leave Require pushed authorization requests off, and add
http://localhost:6274/callbackunder Authorized redirect URIs. Save. - In Flows, create
Course Enrollment Sign-infrom the Basic sign-in template. Add User Consent from Widgets between Authorization and Auth Assertion Generator. Connect both Allow and Deny responses back to User Consent. Turn on Fail flow when user denies consent and save. - On the agent's Flows tab, select Course Enrollment Sign-in as its Sign-in flow and save.
Reuse these resources if they already exist. The resource identifier names the local course tools; you do not need a service at that URL.
Create the Project
- Python
- TypeScript
mkdir langchain-quickstart
cd langchain-quickstart
python3.12 -m venv .venv
source .venv/bin/activate
python -m pip install langchain==1.4.2 langchain-google-genai==4.4.0 requests==2.34.2 python-dotenv==1.2.3
On Windows, use py -3.12 -m venv .venv and activate with .venv\Scripts\Activate.ps1 in PowerShell.
mkdir langchain-quickstart
cd langchain-quickstart
npm init -y
npm pkg set type=module
npm install --save-exact langchain@1.5.14 @langchain/google-genai@2.3.2 zod@4.6.5
npm install --save-dev --save-exact tsx@4.23.15 @types/node@24.19.0
Create .env in this directory. Replace the credential placeholders:
THUNDERID_BASE_URL=https://localhost:8090
AGENT_CLIENT_ID=replace-with-your-client-id
AGENT_SECRET=replace-with-your-client-secret
GOOGLE_API_KEY=replace-with-your-google-api-key
GEMINI_MODEL=gemini-flash-lite-latest
COURSE_RESOURCE=https://courses.example.com
THUNDERID_CA_BUNDLE=./thunderid.pem
Keep .env out of version control. Use your instance's HTTPS URL for THUNDERID_BASE_URL, and the resource server's Identifier for COURSE_RESOURCE.
For a local instance, save its public certificate beside .env:
openssl s_client -connect localhost:8090 -servername localhost </dev/null 2>/dev/null | openssl x509 -out thunderid.pem
You can also copy config/certs/server.cert from your installation directory and rename it to thunderid.pem. For a publicly trusted certificate, omit THUNDERID_CA_BUNDLE.
Build the Agent
The list_courses tool obtains an agent token, validates it with ThunderID, and checks courses:read before returning the catalog.
Save the complete sample beside .env:
- Python
- TypeScript
Copy agent_own.py
import base64
import json
import os
from urllib.parse import quote_plus
import requests
from dotenv import load_dotenv
from langchain.agents import create_agent
from langchain.tools import tool
from langchain_google_genai import ChatGoogleGenerativeAI
load_dotenv()
BASE_URL = os.environ["THUNDERID_BASE_URL"].rstrip("/")
CLIENT_ID = os.environ["AGENT_CLIENT_ID"]
CLIENT_SECRET = os.environ["AGENT_SECRET"]
RESOURCE = os.environ["COURSE_RESOURCE"]
def post(path, data):
credentials = f"{quote_plus(CLIENT_ID)}:{quote_plus(CLIENT_SECRET)}"
basic = base64.b64encode(credentials.encode()).decode()
response = requests.post(
f"{BASE_URL}{path}",
data=data,
headers={"Authorization": f"Basic {basic}"},
verify=os.environ.get("THUNDERID_CA_BUNDLE") or True,
timeout=30,
allow_redirects=False,
)
if response.status_code != 200:
raise RuntimeError(f"{path} returned HTTP {response.status_code}; check the agent configuration.")
return response.json()
def validate_token(token: str, permission: str) -> dict:
identity = post("/oauth2/introspect", {"token": token})
audiences = identity.get("aud", [])
if isinstance(audiences, str):
audiences = [audiences]
if (identity.get("active") is not True
or identity.get("client_id") != CLIENT_ID
or not identity.get("sub")
or RESOURCE not in audiences
or permission not in identity.get("scope", "").split()):
raise PermissionError(f"The token is not valid for {permission} on {RESOURCE}.")
return identity
def read_courses(token: str) -> dict:
identity = validate_token(token, "courses:read")
print("Verified agent:", identity["sub"], flush=True)
result = {"courses": [
{"id": "CS101", "name": "Introduction to Computer Science"},
{"id": "CS205", "name": "Data Structures"},
]}
print("Tool result:", json.dumps(result), flush=True)
return result
@tool
def list_courses() -> dict:
"""List the available courses using the agent's own identity."""
token = post("/oauth2/token", {
"grant_type": "client_credentials", "resource": RESOURCE, "scope": "courses:read",
})["access_token"]
return read_courses(token)
if __name__ == "__main__":
agent = create_agent(
model=ChatGoogleGenerativeAI(model=os.environ["GEMINI_MODEL"], temperature=0),
tools=[list_courses],
system_prompt="Use list_courses to get the catalog. Report only the tool result. If it fails, report the error without retrying.",
)
print("You: List the available courses.", flush=True)
response = agent.invoke({"messages": [{"role": "user", "content": "List the available courses."}]})
print("Agent:", response["messages"][-1].text)
Copy agent_own.ts
import { createAgent, tool } from 'langchain';
import { ChatGoogleGenerativeAI } from '@langchain/google-genai';
import { z } from 'zod';
const BASE_URL = process.env.THUNDERID_BASE_URL!.replace(/\/$/, '');
const CLIENT_ID = process.env.AGENT_CLIENT_ID!;
const CLIENT_SECRET = process.env.AGENT_SECRET!;
const RESOURCE = process.env.COURSE_RESOURCE!;
async function post(path: string, data: Record<string, string>) {
const encode = (value: string) => new URLSearchParams({ v: value }).toString().slice(2);
const basic = Buffer.from(`${encode(CLIENT_ID)}:${encode(CLIENT_SECRET)}`).toString('base64');
const response = await fetch(`${BASE_URL}${path}`, {
method: 'POST',
headers: { Authorization: `Basic ${basic}` },
body: new URLSearchParams(data),
signal: AbortSignal.timeout(30_000),
redirect: 'error',
});
if (!response.ok) {
throw new Error(`${path} returned HTTP ${response.status}; check the agent configuration.`);
}
return response.json();
}
async function validateToken(token: string, permission: string) {
const identity = await post('/oauth2/introspect', { token });
const audiences = Array.isArray(identity.aud) ? identity.aud : [identity.aud];
if (identity.active !== true || identity.client_id !== CLIENT_ID || !identity.sub
|| !audiences.includes(RESOURCE) || !identity.scope?.split(' ').includes(permission)) {
throw new Error(`The token is not valid for ${permission} on ${RESOURCE}.`);
}
return identity;
}
async function readCourses(token: string) {
const identity = await validateToken(token, 'courses:read');
console.log('Verified agent:', identity.sub);
const result = { courses: [
{ id: 'CS101', name: 'Introduction to Computer Science' },
{ id: 'CS205', name: 'Data Structures' },
] };
console.log('Tool result:', JSON.stringify(result));
return result;
}
const listCourses = tool(async () => {
const { access_token } = await post('/oauth2/token', {
grant_type: 'client_credentials', resource: RESOURCE, scope: 'courses:read',
});
return readCourses(access_token);
}, {
name: 'list_courses',
description: "List the available courses using the agent's own identity.",
schema: z.object({}),
});
const agent = createAgent({
model: new ChatGoogleGenerativeAI({ model: process.env.GEMINI_MODEL!, temperature: 0 }),
tools: [listCourses],
systemPrompt: 'Use list_courses to get the catalog. Report only the tool result. If it fails, report the error without retrying.',
});
console.log('You: List the available courses.');
const response = await agent.invoke({ messages: [{ role: 'user', content: 'List the available courses.' }] });
console.log('Agent:', response.messages.at(-1)?.content);
The enroll_course tool starts sign-in when called. After consent, it validates the delegated token and checks courses:enroll before enrolling the student identified by that token.
After sign-in and consent, the agent exchanges the authorization code for a delegated token using PKCE.
Save the complete sample beside .env. Its callback validates state and waits up to 180 seconds for sign-in and consent:
- Python
- TypeScript
Copy agent_obo.py
import base64
import json
import os
import secrets
import hashlib
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
from threading import Lock
from urllib.parse import parse_qs, quote_plus, urlencode, urlsplit
import requests
from dotenv import load_dotenv
from langchain.agents import create_agent
from langchain.tools import tool
from langchain_google_genai import ChatGoogleGenerativeAI
load_dotenv()
BASE_URL = os.environ["THUNDERID_BASE_URL"].rstrip("/")
CLIENT_ID = os.environ["AGENT_CLIENT_ID"]
CLIENT_SECRET = os.environ["AGENT_SECRET"]
RESOURCE = os.environ["COURSE_RESOURCE"]
REDIRECT_URI = "http://localhost:6274/callback"
def post(path, data):
credentials = f"{quote_plus(CLIENT_ID)}:{quote_plus(CLIENT_SECRET)}"
basic = base64.b64encode(credentials.encode()).decode()
response = requests.post(
f"{BASE_URL}{path}",
data=data,
headers={"Authorization": f"Basic {basic}"},
verify=os.environ.get("THUNDERID_CA_BUNDLE") or True,
timeout=30,
allow_redirects=False,
)
if response.status_code != 200:
raise RuntimeError(f"{path} returned HTTP {response.status_code}; check the agent configuration.")
return response.json()
def get_user_token():
verifier = secrets.token_urlsafe(32)
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
state = secrets.token_urlsafe(32)
result = {}
class Callback(BaseHTTPRequestHandler):
def do_GET(self):
url = urlsplit(self.path)
params = parse_qs(url.query)
valid = (
url.path == "/callback"
and len(params.get("state", [])) == 1
and secrets.compare_digest(params["state"][0], state)
and ((len(params.get("code", [])) == 1 and "error" not in params)
or (len(params.get("error", [])) == 1 and "code" not in params))
)
self.send_response(200 if valid else 400)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(b"Return to your terminal." if valid else b"Invalid callback.")
if valid:
result.update(params)
def log_message(self, *args):
pass
params = {
"response_type": "code", "client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI, "scope": "openid courses:enroll", "resource": RESOURCE, "state": state, "prompt": "consent",
"code_challenge": challenge, "code_challenge_method": "S256",
}
with HTTPServer(("localhost", 6274), Callback) as server:
server.timeout = 1
print("Open this URL in your browser to sign in:", flush=True)
print(f"{BASE_URL}/oauth2/authorize?{urlencode(params)}", flush=True)
deadline = time.monotonic() + 180
while not result and time.monotonic() < deadline:
server.handle_request()
if not result:
raise TimeoutError("Sign-in timed out after 180 seconds. Run the sample again.")
if "error" in result:
raise RuntimeError("Sign-in was denied or failed. Run the sample again.")
return post("/oauth2/token", {
"grant_type": "authorization_code", "code": result["code"][0],
"redirect_uri": REDIRECT_URI, "code_verifier": verifier,
})["access_token"]
def validate_token(token: str, permission: str) -> dict:
identity = post("/oauth2/introspect", {"token": token})
audiences = identity.get("aud", [])
if isinstance(audiences, str):
audiences = [audiences]
if (identity.get("active") is not True
or identity.get("client_id") != CLIENT_ID
or not identity.get("sub")
or RESOURCE not in audiences
or permission not in identity.get("scope", "").split()):
raise PermissionError(f"The token is not valid for {permission} on {RESOURCE}.")
return identity
enrollments = {}
enrollment_lock = Lock()
def record_enrollment(course_id: str, token: str) -> dict:
identity = validate_token(token, "courses:enroll")
# Decode only after introspection validates this exact token.
# ThunderID's introspection response does not include the actor claim.
payload = token.split(".")[1]
claims = json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)))
if not claims.get("act", {}).get("sub"):
raise PermissionError("Enrollment requires a delegated user token.")
student_id = identity["sub"]
enrolled = enrollments.setdefault(student_id, set())
status = "already_enrolled" if course_id in enrolled else "enrolled"
enrolled.add(course_id)
result = {"student_id": student_id, "course_id": course_id, "status": status}
print("Verified agent:", claims["act"]["sub"], flush=True)
print("Tool result:", json.dumps(result), flush=True)
return result
@tool
def enroll_course(course_id: str) -> dict:
"""Enroll the signed-in student in CS101 or CS205 after their consent."""
if course_id not in {"CS101", "CS205"}:
return {"status": "not_enrolled", "reason": "Unknown course ID."}
with enrollment_lock:
print(f"Enrolling you in {course_id} needs your permission.", flush=True)
return record_enrollment(course_id, get_user_token())
if __name__ == "__main__":
agent = create_agent(
model=ChatGoogleGenerativeAI(model=os.environ["GEMINI_MODEL"], temperature=0),
tools=[enroll_course],
system_prompt="Use enroll_course for the requested course. The tool handles sign-in and consent. Report success only if the tool succeeds. If it fails or consent is denied, stop without retrying.",
)
print("You: Enroll me in CS205.", flush=True)
response = agent.invoke({"messages": [{"role": "user", "content": "Enroll me in CS205."}]})
print("Agent:", response["messages"][-1].text)
Copy agent_obo.ts
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
import { createServer } from 'node:http';
import { createAgent, tool } from 'langchain';
import { ChatGoogleGenerativeAI } from '@langchain/google-genai';
import { z } from 'zod';
const BASE_URL = process.env.THUNDERID_BASE_URL!.replace(/\/$/, '');
const CLIENT_ID = process.env.AGENT_CLIENT_ID!;
const CLIENT_SECRET = process.env.AGENT_SECRET!;
const RESOURCE = process.env.COURSE_RESOURCE!;
const REDIRECT_URI = 'http://localhost:6274/callback';
async function post(path: string, data: Record<string, string>) {
const encode = (value: string) => new URLSearchParams({ v: value }).toString().slice(2);
const basic = Buffer.from(`${encode(CLIENT_ID)}:${encode(CLIENT_SECRET)}`).toString('base64');
const response = await fetch(`${BASE_URL}${path}`, {
method: 'POST',
headers: { Authorization: `Basic ${basic}` },
body: new URLSearchParams(data),
signal: AbortSignal.timeout(30_000),
redirect: 'error',
});
if (!response.ok) {
throw new Error(`${path} returned HTTP ${response.status}; check the agent configuration.`);
}
return response.json();
}
async function getUserToken(): Promise<string> {
const verifier = randomBytes(32).toString('base64url');
const challenge = createHash('sha256').update(verifier).digest('base64url');
const state = randomBytes(32).toString('base64url');
const params = new URLSearchParams({
response_type: 'code', client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI, scope: 'openid courses:enroll', resource: RESOURCE, state, prompt: 'consent',
code_challenge: challenge, code_challenge_method: 'S256',
});
const code = await new Promise<string>((resolve, reject) => {
const finish = (error?: Error, value?: string) => {
clearTimeout(timer);
server.close();
if (error) reject(error);
else resolve(value!);
};
const server = createServer((req, res) => {
const url = new URL(req.url ?? '/', REDIRECT_URI);
const receivedState = Buffer.from(url.searchParams.get('state') ?? '');
const expectedState = Buffer.from(state);
const valid = url.pathname === '/callback'
&& url.searchParams.getAll('state').length === 1
&& receivedState.length === expectedState.length
&& timingSafeEqual(receivedState, expectedState)
&& ((url.searchParams.getAll('code').length === 1 && !url.searchParams.has('error'))
|| (url.searchParams.getAll('error').length === 1 && !url.searchParams.has('code')));
res.writeHead(valid ? 200 : 400, { 'Content-Type': 'text/plain', Connection: 'close' });
res.end(valid ? 'Return to your terminal.' : 'Invalid callback.');
if (!valid) return;
if (url.searchParams.has('error')) finish(new Error('Sign-in was denied or failed.'));
else finish(undefined, url.searchParams.get('code')!);
});
const timer = setTimeout(() => finish(new Error('Sign-in timed out. Run the sample again.')), 180_000);
server.on('error', error => finish(error));
server.listen(6274, 'localhost', () => {
console.log('Open this URL in your browser to sign in:');
console.log(`${BASE_URL}/oauth2/authorize?${params}`);
});
});
return (await post('/oauth2/token', {
grant_type: 'authorization_code', code,
redirect_uri: REDIRECT_URI, code_verifier: verifier,
})).access_token;
}
async function validateToken(token: string, permission: string) {
const identity = await post('/oauth2/introspect', { token });
const audiences = Array.isArray(identity.aud) ? identity.aud : [identity.aud];
if (identity.active !== true || identity.client_id !== CLIENT_ID || !identity.sub
|| !audiences.includes(RESOURCE) || !identity.scope?.split(' ').includes(permission)) {
throw new Error(`The token is not valid for ${permission} on ${RESOURCE}.`);
}
return identity;
}
const enrollments = new Map<string, Set<string>>();
let enrolling = false;
async function recordEnrollment(courseId: string, token: string) {
const identity = await validateToken(token, 'courses:enroll');
// Decode only after introspection validates this exact token.
// ThunderID's introspection response does not include the actor claim.
const claims = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString());
if (!claims.act?.sub) throw new Error('Enrollment requires a delegated user token.');
const studentId: string = identity.sub;
if (!enrollments.has(studentId)) enrollments.set(studentId, new Set());
const enrolled = enrollments.get(studentId)!;
const status = enrolled.has(courseId) ? 'already_enrolled' : 'enrolled';
enrolled.add(courseId);
const result = { student_id: studentId, course_id: courseId, status };
console.log('Verified agent:', claims.act.sub);
console.log('Tool result:', JSON.stringify(result));
return result;
}
const enrollCourse = tool(async ({ course_id }: { course_id: string }) => {
if (!['CS101', 'CS205'].includes(course_id)) {
return { status: 'not_enrolled', reason: 'Unknown course ID.' };
}
if (enrolling) throw new Error('An enrollment is already awaiting consent.');
enrolling = true;
try {
console.log(`Enrolling you in ${course_id} needs your permission.`);
return await recordEnrollment(course_id, await getUserToken());
} finally {
enrolling = false;
}
}, {
name: 'enroll_course',
description: 'Enroll the signed-in student in CS101 or CS205 after their consent.',
schema: z.object({ course_id: z.string() }),
});
const agent = createAgent({
model: new ChatGoogleGenerativeAI({ model: process.env.GEMINI_MODEL!, temperature: 0 }),
tools: [enrollCourse],
systemPrompt: 'Use enroll_course for the requested course. The tool handles sign-in and consent. Report success only if the tool succeeds. If it fails or consent is denied, stop without retrying.',
});
console.log('You: Enroll me in CS205.');
const response = await agent.invoke({ messages: [{ role: 'user', content: 'Enroll me in CS205.' }] });
console.log('Agent:', response.messages.at(-1)?.content);
Tokens stay inside the tool implementation. LangChain exposes only the tool's business arguments to the model.
Run the Agent
- Python
- TypeScript
python agent_own.py
export NODE_EXTRA_CA_CERTS="$PWD/thunderid.pem"
npx tsx --env-file=.env agent_own.ts
Expect a result like this:
You: List the available courses.
Verified agent: <agent-id>
Tool result: {"courses":[{"id":"CS101","name":"Introduction to Computer Science"},{"id":"CS205","name":"Data Structures"}]}
Agent: The available courses are CS101 and CS205.
Verified agent should match the Agent ID in the Console. The tool used the agent's permissions without student sign-in.
- Python
- TypeScript
python agent_obo.py
export NODE_EXTRA_CA_CERTS="$PWD/thunderid.pem"
npx tsx --env-file=.env agent_obo.ts
The sample asks to enroll you in CS205, then prints a sign-in link:
You: Enroll me in CS205.
Enrolling you in CS205 needs your permission.
Open this URL in your browser to sign in:
https://localhost:8090/oauth2/authorize?...
- Open the printed URL. Sign in as the student assigned the Student role.
- On the consent screen, turn on
courses:enrolland click Allow. - Return to the terminal after the browser reaches the local redirect URI.
Expect a result like this:
Verified agent: <agent-id>
Tool result: {"student_id":"<student-id>","course_id":"CS205","status":"enrolled"}
Agent: You are enrolled in CS205.
student_id should match your signed-in student's User ID. The sample reads the agent's identity from the validated token's act.sub claim. Enrollment records disappear when the process exits.
To try denial, run the sample again and click Deny. The tool fails without recording an enrollment. Each run requests fresh consent.
The agent's wording and JSON spacing can vary. The Tool result line confirms the operation completed.
In PowerShell, set $env:NODE_EXTRA_CA_CERTS = "$PWD/thunderid.pem" before running the sample. Omit the certificate variable for a publicly trusted certificate.
Resolve common errors
| Symptom | Check |
|---|---|
HTTP 401 from token or introspection requests | Use the agent's current Client ID and secret. On Advanced, confirm Client authentication method is client_secret_basic. |
invalid_target | Match COURSE_RESOURCE to the resource server's Identifier. |
| Certificate verification fails | Export the running instance's certificate. For Python, set THUNDERID_CA_BUNDLE to that file. For TypeScript, set NODE_EXTRA_CA_CERTS before starting the process. |
| Gemini reports a model or quota error | Check GEMINI_MODEL, your API key, and your Google project quota. |
| Symptom | Check |
|---|---|
Token is not valid for courses:read | Assign Course Reader to the agent. |
| Symptom | Check |
|---|---|
Token is not valid for courses:enroll | Assign Student to the signed-in user and allow the permission on the consent screen. |
| No consent screen appears | Select Course Enrollment Sign-in on the agent's Flows tab. Confirm the flow includes User Consent. |
| Sign-in fails before the redirect | Enable delegated mode and register http://localhost:6274/callback exactly. |
Port 6274 is in use, or sign-in times out | Stop the other sample, or run this sample again and complete sign-in within 180 seconds. |
What's Next
- Agent tokens explains own-identity access.
- Agent access on behalf of users covers delegated access and token refresh.
- Choose another framework to try the examples with Google ADK, Vercel AI SDK, or CrewAI.
- Control agent access covers permission checks when moving tools behind a business API.