PAM Discovery Agent - Build Example
The PAM Active Directory Discovery Agent is a Python-based AI agent demo that connects to a 12Port PAM vault, runs an Active Directory discovery, and uses OpenAI to organize and tag the discovered assets before importing them back into the vault. It is designed to reduce the manual effort involved in onboarding large numbers of AD-discovered machines into a structured, tagged vault hierarchy.
This document covers the agent's architecture, how it interacts with the 12Port API and MCP layer, the OpenAI integration, and how to deploy and configure it for your environment.
Architecture Overview
The agent is a sequential pipeline. Each stage feeds into the next, and all configuration is driven by a single .env file. The pipeline can run interactively with user prompts at key decision points, or fully autonomously with AUTONOMOUS_MODE=true.
PAM Vault (API token Auth)
│
▼
MCP Spec Discovery
│
▼
OpenAI Key Retrieval -- workflow-protected credential via PAM vault
│
▼
OpenAI Key Validation
│
▼
AD Discovery -- LDAP connector + vault import API
│
▼
Taxonomy Export -- PUT /api/taxonomy/export
│
▼
OpenAI Analysis -- folder structure + tag assignment
│
▼
Export / Import -- report, CSV, or direct vault import
│
▼
Exit on Completion
The agent is structured as a set of focused modules rather than a single script. Each module owns a specific concern:
| Module | Responsibility |
|---|---|
main.py |
Pipeline orchestration and error handling |
mcp_client.py |
Authenticated HTTP session, MCP spec fetch, endpoint indexing |
workflow.py |
Asset resolution, credential retrieval, workflow state machine |
vault_client.py |
12Port main REST API calls (discovery, taxonomy, import) |
discovery.py |
AD discovery orchestration and taxonomy export |
openai_client.py |
OpenAI key validation, asset analysis, chat loop |
exporter.py |
Report generation, CSV construction, vault import pipeline |
Authentication
The agent uses a JWT bearer token (12Port API Token) for all requests to the 12Port vault. This token is configured in .env as PAM_JWT_TOKEN and is set once on the requests.Session object at startup:
session.headers.update({
"Authorization": f"Bearer {cfg.pam_jwt_token}",
"Content-Type": "application/json",
"Accept": "application/json",
})
The same session, and therefore the same token, is used for both MCP endpoint calls and main API calls. The MCP base URL is derived from PAM_BASE_URL by stripping the trailing /api segment (if present), since MCP paths already include /api/mcp/... in their path strings.
PAM_BASE_URL: https://host/ztna/YourTenant/root/api
MCP base URL: https://host/ztna/YourTenant/root
Main API URL: https://host/ztna/YourTenant/root/api/...
MCP Layer
The agent discovers its available vault operations by fetching an OpenAPI 3.0 spec from the vault's MCP service URL (MCP_SERVICE_URL). This spec is the authoritative source for endpoint paths, parameter formats, error codes, and workflow sequencing. On startup the agent fetches the spec, indexes all endpoints by operationId, and prints a summary:
======================================================================
MCP Endpoints Discovered: 6
======================================================================
[GET] /api/mcp/get-asset-credentials/{id}
operationId : credentials_1
description : Returns the credentials (username and password) stored in the asset.
[GET] /api/mcp/search-assets
operationId : polySearch_1
...
The --dump-spec CLI flag writes the full spec to mcp_spec_dump.json for inspection.
Credential Retrieval and Workflow
The OpenAI API key is stored as a credential in the vault. The agent retrieves it via the get-asset-credentials MCP endpoint. If the credential is workflow-protected, the agent handles the full approval cycle automatically.
The workflow state machine works as follows:
- Call
get-asset-credentials. HTTP 200 means direct access with no workflow needed. - HTTP 500 with
MSG-00863in the response body means a workflow request must be submitted first. - HTTP 500 with
MSG-00864means a request is already pending; skip submission and go straight to polling. - After submission, probe all eight
RequestActionenum values viaget-request-statusto identify which action type the vault assigned. The probe returningstatus=Activeis the correct one. - Poll
get-request-statuson that action type until status changes fromActive.Openmeans approved so callget-asset-credentialsto retrieve the credential.Closedmeans rejected or expired and offer to resubmit (or auto-resubmit ifWORKFLOW_AUTO_RESUBMIT=true).RestrictedorMFAare terminal and exit with a permission error.
Pending workflow state is persisted to .pending_request.json so the agent can resume across sessions without submitting duplicate requests. The file is deleted when the request reaches a terminal state or is formally completed via complete-request.
On exit, the agent offers to call complete-request to formally close any open workflow request. This keeps the PAM audit trail clean.
Note
For a more detailed guide focused solely on Credential Retrieval, please see our Building an AI Agent That Retrieves Credentials Securely from the 12Port Vault guide.
Active Directory Discovery
Asset discovery uses the vault's built-in LDAP connector rather than querying AD directly. The agent calls POST /api/import/create-ad/{container-id} with action=1 (validate/discover), which triggers the vault's own discovery job using the configured LDAP connector.
Before triggering discovery the agent resolves several dependencies:
LDAP connector: If AD_LDAP_ID is not set, the agent calls GET /api/config/enabled-ldap-configurations/{root-id} and either auto-selects the first enabled connector (AD_LDAP_AUTO_SELECT=true) or prompts the user.
Asset type UUID: The asset type name (e.g. Windows Host) must be resolved to its UUID for the discovery payload. The agent calls GET /api/asset/list-types/{root-id} to find the type by name, then calls GET /api/type/get/{type-id} to retrieve the full type record with its fields. The full type record is also used to auto-discover the taxonomy ID; the agent looks for a field with fieldType: "Taxonomy" and reads its txid.
Ref asset UUID: The AD_REF_ASSET name is resolved to a UUID via the MCP search-assets endpoint.
The discovery request body:
{
"ldapid": "<ldap-connector-uuid>",
"query": "(&(objectCategory=Computer)(operatingSystem=Windows Server*))",
"tid": "<asset-type-uuid>",
"aid": "<ref-asset-uuid>",
"cron": null
}
Note: Sending empty strings for
tid,aid, orcroncauses aMSG-00400error from the vault. Omit fields that are not needed or usenullforcron.
After triggering discovery, the agent polls GET /api/import/import/{import-id} until the status field returns Success. It then retrieves entries via GET /api/import/entries/{container-id}/{import-id} and extracts the name, host, and description fields from each entry. This endpoint is paginated so the agent uses a page size of 200 and loops until a page returns fewer results than the page size, collecting all pages into a single list before proceeding. Results are saved to temp/discovered_assets.json.
Taxonomy Export
The vault taxonomy defines the controlled vocabulary used for tagging assets. The agent exports it for use in the OpenAI analysis step.
The taxonomy export call is:
PUT /api/taxonomy/export
Body: ["<taxonomy-uuid>"]
The response is {"value": "<json-string>"} where the value field contains an escaped JSON string representing the full taxonomy tree. The agent parses this string, strips @class metadata fields, and saves the clean tree to temp/taxonomy.json.
Tag paths are built by traversing from the root's children, not the root itself. For example, given a root node named "LECA Segmentation" with a child "Component" that has a child "Server" with a child "Windows", the valid tag path is Component :: Server :: Windows, not LECA Segmentation :: Component :: Server :: Windows.
OpenAI Analysis
With the discovered assets and taxonomy saved to temp files, the agent submits both to OpenAI for analysis. The system prompt is loaded from prompts/analysis_prompt.md, which is an editable plain-text file. No code changes required to adjust the analysis behavior.
The prompt instructs the model to return a JSON array with one object per asset:
[
{
"name": "CONTOSO-DC01",
"host": "CONTOSO-DC01.company.com",
"folder_path": "Domain Controllers",
"tags": ["Component :: Server :: Windows", "Environment :: Production"]
}
]
Scaling for Large Environments: Batching and Taxonomy Flattening
Sending all assets and the full taxonomy tree in a single OpenAI request works well for small environments but becomes a practical problem at scale. A large AD environment with hundreds of assets and a deep taxonomy tree can produce a combined prompt that exceeds OpenAI's context window, causes slow responses, and increases cost unnecessarily.
The discovery agent addresses this with two complementary techniques applied together.
Technique 1 — Asset batching. Rather than sending all assets in one request, the agent splits them into configurable batches (default: 50 assets per batch) and submits each batch as a separate OpenAI call. The results from all batches are merged before tag validation runs. This keeps each prompt well within context limits regardless of how many assets AD returns.
for i in range(total_batches):
start = i * batch_size
end = min(start + batch_size, total)
batch = assets[start:end]
batch_results = _analyze_batch(
client=client,
batch=batch,
flat_tags=flat_tags,
model=model,
batch_num=i + 1,
total_batches=total_batches,
)
all_results.extend(batch_results)
Technique 2 — Taxonomy flattening. The full taxonomy tree exported from the vault is a nested JSON structure with names, descriptions, synonyms, selectable flags, and recursive child arrays. This is useful for understanding the taxonomy structure but is far more data than the model actually needs to assign tags. In our example, what the model needs is simply the list of valid tag paths, nothing more.
Instead of injecting the full nested tree into the prompt, the agent pre-processes it into a flat sorted list of valid tag path strings:
def _build_flat_tag_list(taxonomy: list[dict]) -> list[str]:
paths: list[str] = []
def _walk(nodes: list[dict], prefix: str) -> None:
for node in nodes:
name = node.get("name", "").strip()
full_path = f"{prefix} :: {name}" if prefix else name
if node.get("selectable", True):
paths.append(full_path)
_walk(node.get("terms", []), full_path)
for root in taxonomy:
_walk(root.get("terms", []), "") # skip root node name
return sorted(paths)
For the LECA Segmentation taxonomy used in this project, the full nested JSON tree is several kilobytes. The flat list reduces to something like this:
[
"Application :: Access Wall",
"Component :: Application Server",
"Component :: Database",
"Component :: Desktop",
"Component :: IP List :: Everything",
"Component :: IP List :: Management Server",
"Component :: Server :: AIX",
"Component :: Server :: Linux",
"Component :: Server :: Solaris",
"Component :: Server :: Unix",
"Component :: Server :: Windows",
"Component :: WEB Server",
"Environment :: Development",
"Environment :: Production",
"Environment :: Staging",
"Location"
]
The model receives exactly the same information it needs for tagging, the complete set of valid paths, in a fraction of the time and tokens. The flat list is built once per run and shared across all batches, so there is no redundant processing.
Why this matters in practice. At 50 assets per batch with a flattened taxonomy, the per-request prompt size is predictable and bounded. A 500-asset environment runs as 10 sequential batches, each completing in a few seconds. The total time is linear and the cost per run is proportional to the number of assets and not subject to context-limit failures or unpredictable token spikes from large taxonomy trees.
The batch size is configurable via OPENAI_ANALYSIS_BATCH_SIZE in .env. Environments with very long asset names or descriptions may benefit from a smaller batch size. Environments with small taxonomies and short hostnames can safely increase it.
Tag Validation
After all batches are merged, the agent validates every tag against the taxonomy before the results are used. A valid tag set is built by walking the taxonomy tree and collecting all selectable term paths. Any tag returned by the model that does not exist in this set is stripped and logged as a warning. This prevents hallucinated taxonomy terms from reaching the import process regardless of what the model returns.
def _build_valid_tag_set(taxonomy: list[dict]) -> set[str]:
valid: set[str] = set()
def _walk(nodes: list[dict], prefix: str) -> None:
for node in nodes:
name = node.get("name", "").strip()
full_path = f"{prefix} :: {name}" if prefix else name
if node.get("selectable", True):
valid.add(full_path)
_walk(node.get("terms", []), full_path)
for root in taxonomy:
_walk(root.get("terms", []), "") # skip root name
return valid
Note that _build_flat_tag_list() (used for prompt construction) and _build_valid_tag_set() (used for validation) traverse the same tree using the same logic; one returns a list, the other returns a set. The set is used for O(1) membership checks during validation; the list is used for ordered prompt injection.
CSV Construction and Import
The analysis results are used to build a 12Port-compatible CSV import file. The column order matches the vault's Import From CSV specification exactly:
Index, Name, Description, Type, Parents, Base, Members, Host, User,
Password, Private Key, Private Key Password, Included, Excluded, Tags
Three structural rules govern how the CSV is built:
Container rows come first. The vault processes rows top to bottom, so every folder path level must exist as a Container row before any asset row that references it as a parent. The agent enumerates all ancestor paths across all assets, deduplicates them, sorts by depth (shallow before deep), and writes one container row per unique path.
Shadow placeholder. When CSV_SHADOW_PLACEHOLDER=true, a placeholder asset row is inserted as the very first row. All non-container asset rows reference it via Shadow:<placeholder-name> in the Members column. This resolves the vault's MSG-00732 error that can occur when a Shadow reference points to an asset in a different container that the import batch cannot locate.
Tags are newline-delimited. When an asset has multiple tags, they are joined with \n within the same CSV cell, not comma-separated. This matches the vault's multi-value tag format.
The vault import itself requires five API calls in sequence. The import record's completion is tracked via the stat array:
1. POST /api/import/create/{container-id}?action=0
Body: { name, modified, size, type, content: <base64 CSV> }
> Returns import-id
2. GET /api/import/entries/{container-id}/{import-id}
> Returns entry IDs needed for steps 3 and 5
3. PUT /api/import/start-validation-multi/{container-id}/{import-id}
Body: ["<entry-id>", "<entry-id>", ...]
> Triggers validation: Draft -> Validated
4. Poll GET /api/import/import/{import-id}
until stat contains Validated entries and zero Draft entries
5. PUT /api/import/start-import-multi/{container-id}/{import-id}
Body: ["<entry-id>", "<entry-id>", ...]
> Triggers import: Validated -> Imported
6. Poll until stat contains Imported entries and zero Draft entries
Note:
PUT /api/import/start-import/{container-id}/{import-id}(without-multi) does not trigger validation despite returning HTTP 200. The correct endpoint isstart-validation-multiwith the entry IDs array as the request body.
Deployment
Requirements
- Python 3.13+
- A 12Port PAM vault instance with API token access with the required permission
- An LDAP connector configured in the vault
- A vault asset storing an OpenAI API key
Installation
git clone <repo-url>
cd pam-discovery-agent
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env
# Edit .env with your vault URL, JWT token, and asset configuration
Minimum .env for first run
PAM_BASE_URL=https://your-vault/ztna/YourTenant/root/api
PAM_JWT_TOKEN=<your-api-token>
MCP_SERVICE_URL=https://your-vault/ztna/YourTenant/root/.well-known/mcp-agent.json
VERIFY_TLS=false
ASSET_ID=<uuid-of-openai-key-asset>
AD_ASSET_TYPE=Windows Host
AD_LDAP_QUERY=(&(objectCategory=Computer)(operatingSystem=Windows Server*))
With these values set, the agent will prompt for anything else it needs at runtime; LDAP connector, container ID, ref asset, and export action.
Autonomous operation
For scheduled or unattended runs, populate all required values in .env and set:
AUTONOMOUS_MODE=true
POST_ANALYSIS_ACTION=import
WORKFLOW_AUTO_COMPLETE=true
CSV_SHADOW_PLACEHOLDER=true
CSV_SHADOW_PLACEHOLDER_TYPE=Windows Host
VAULT_IMPORT_CONTAINER_ID=<uuid>
Customizing the analysis
Edit prompts/analysis_prompt.md to adjust how the model organizes and tags assets. The {{ASSETS}} and {{TAXONOMY}} placeholders are replaced at runtime. Comment lines starting with # are stripped before the prompt is sent to OpenAI.
The most impactful changes are in the folder path guidance. The default prompt instructs the model to keep paths 2–4 levels deep and to use asset naming patterns (DC, RDS, HA, etc.) for grouping. Organizations with consistent naming conventions can add those patterns explicitly to get more accurate groupings.
Extending the Agent
The modular structure makes it straightforward to add new capabilities without touching the core pipeline.
Supporting additional asset types: The AD_ASSET_TYPE and AD_LDAP_QUERY values can be changed per run to discover Unix hosts, network appliances, or any other type supported by the vault. The CSV construction and import pipeline is type-agnostic since it uses whatever asset type name is configured.
Adding more export formats: The exporter.py module handles all output. A new export format is a new function that takes analysis_results and cfg and writes to EXPORT_DIR. Hook it into run_post_analysis() and add a menu option.
Custom analysis logic: The OpenAI analysis step can be replaced with any function that takes a list of asset dicts and a taxonomy tree and returns a list of {name, host, folder_path, tags} objects. The rest of the agent's pipeline does not care how the analysis was produced.
Scheduling: The agent has no built-in scheduler, but since it runs cleanly from the command line and exits on completion, it works well with cron or any task scheduler. Set AUTONOMOUS_MODE=true and POST_ANALYSIS_ACTION=import to get a fully unattended run.