Jump to: 🔥 Challenge News ⚡ Intel 🔬 Research Labs 📡 All News →
🔧 Tool-Calling Patterns

Tool-Calling Patterns for Security AI

Copy-paste Python patterns for connecting AI agents to your SIEM, threat intelligence, EDR, ticketing, and notification systems.

🔄
Complete Agentic Loop with Tool-Calling
The full pattern — multi-turn loop that handles tool calls until the agent decides it's done. Start here.
CoreEssential
▶
Python — Complete agent loop (copy and extend)
import anthropic import json client = anthropic.Anthropic() # Uses ANTHROPIC_API_KEY env var # ── Define your tools ────────────────────────────────────────── TOOLS = [ { "name": "query_siem", "description": "Execute a Splunk SPL query and return matching events as JSON", "input_schema": { "type": "object", "properties": { "spl": {"type": "string", "description": "SPL query to execute"}, "earliest": {"type": "string", "description": "Time range e.g. -24h, -7d", "default": "-24h"} }, "required": ["spl"] } }, { "name": "lookup_ip", "description": "Check an IP address against threat intelligence feeds", "input_schema": { "type": "object", "properties": { "ip": {"type": "string", "description": "IPv4 or IPv6 address to check"} }, "required": ["ip"] } } ] # ── Implement your tools ─────────────────────────────────────── def query_siem(spl: str, earliest: str = "-24h") -> dict: """Replace with your actual Splunk API call""" # from splunklib import client as splunk_client # service = splunk_client.connect(host=HOST, port=8089, username=USER, password=PASS) # job = service.jobs.create(spl, earliest_time=earliest) # return {"events": list(job.results()), "count": job["resultCount"]} return {"events": [], "count": 0, "note": "Replace with real Splunk connection"} def lookup_ip(ip: str) -> dict: """Replace with VirusTotal, AbuseIPDB, or CERT-In TI feed""" # import requests # resp = requests.get(f"https://www.virustotal.com/api/v3/ip_addresses/{ip}", # headers={"x-apikey": VT_KEY}) # data = resp.json()["data"]["attributes"] # return {"malicious": data["last_analysis_stats"]["malicious"] > 0, "score": ...} return {"malicious": False, "score": 0, "note": "Replace with real TI lookup"} # ── Tool dispatcher ──────────────────────────────────────────── def dispatch_tool(name: str, inputs: dict) -> str: """Call the right tool and return result as JSON string""" try: if name == "query_siem": result = query_siem(**inputs) elif name == "lookup_ip": result = lookup_ip(**inputs) else: result = {"error": f"Unknown tool: {name}"} return json.dumps(result) except Exception as e: return json.dumps({"error": str(e)}) # ── The agentic loop ─────────────────────────────────────────── def run_security_agent(alert: str, max_tool_calls: int = 10) -> str: """ Run the agent to completion. Returns the agent's final text response. max_tool_calls prevents runaway agents from consuming budget. """ messages = [{"role": "user", "content": alert}] tool_call_count = 0 system_prompt = """You are a SOC Level 2 analyst. Use your tools to investigate alerts. Ground every factual claim in tool results. Return your findings as JSON with fields: verdict, confidence, evidence[], mitre_techniques[], next_steps[]""" while True: response = client.messages.create( model="claude-sonnet-4-6", max_tokens=2048, system=system_prompt, tools=TOOLS, messages=messages ) # Agent finished reasoning if response.stop_reason == "end_turn": return "".join(b.text for b in response.content if hasattr(b, "text")) # Safety: prevent infinite loops if tool_call_count >= max_tool_calls: messages.append({"role": "assistant", "content": response.content}) messages.append({ "role": "user", "content": [{"type": "text", "text": "Max tool calls reached. Please provide your best verdict with the information gathered so far."}] }) continue # Process tool calls messages.append({"role": "assistant", "content": response.content}) tool_results = [] for block in response.content: if block.type == "tool_use": tool_call_count += 1 result_json = dispatch_tool(block.name, block.input) tool_results.append({ "type": "tool_result", "tool_use_id": block.id, "content": result_json }) messages.append({"role": "user", "content": tool_results}) # ── Usage ────────────────────────────────────────────────────── if __name__ == "__main__": alert = """ EventCode=4625 TargetUserName=admin IpAddress=185.220.101.45 FailureReason=Wrong password LogonType=3 Count=147 TimeGenerated=2024-01-15 03:42:18 IST """ result = run_security_agent(alert) print(result)
Key insight: The loop runs until stop_reason == "end_turn" — that's when the agent decides it has enough information. The max_tool_calls guard is essential in production. Set ANTHROPIC_API_KEY as an environment variable, never hardcode it.
📊
Splunk Tool — SPL Query via REST API
Connect your agent to Splunk and execute SPL queries programmatically.
SIEMSplunk
▶
import requests import time import json def query_splunk(spl: str, earliest: str = "-24h", latest: str = "now", host: str = "https://splunk:8089", username: str = "admin", password: str = "...", max_results: int = 100) -> dict: """ Execute a Splunk SPL query and return results. For an agent: keep max_results low (100-500) to control token usage. """ session = requests.Session() session.auth = (username, password) session.verify = False # For self-signed certs — use proper CA in production # Create search job create_resp = session.post(f"{host}/services/search/jobs", data={ "search": f"search {spl}", "earliest_time": earliest, "latest_time": latest, "output_mode": "json", "count": max_results }) sid = create_resp.json()["sid"] # Poll until complete while True: status = session.get(f"{host}/services/search/jobs/{sid}", params={"output_mode": "json"}).json() state = status["entry"][0]["content"]["dispatchState"] if state in ("DONE", "FAILED"): break time.sleep(1) # Fetch results results = session.get(f"{host}/services/search/jobs/{sid}/results", params={"output_mode": "json", "count": max_results}).json() return { "count": len(results.get("results", [])), "events": results.get("results", [])[:max_results], "query": spl, "time_range": f"{earliest} to {latest}" } # Tool definition for the agent SPLUNK_TOOL = { "name": "query_splunk", "description": """Execute a Splunk SPL query to search security events. Use for: finding related alerts, checking login history, searching for IOCs. IMPORTANT: Keep queries specific — avoid broad searches that return millions of events. Always use time constraints (earliest=-24h) to limit scope.""", "input_schema": { "type": "object", "properties": { "spl": {"type": "string", "description": "SPL query (without the leading 'search' keyword)"}, "earliest": {"type": "string", "description": "Time range start: -1h, -24h, -7d", "default": "-24h"}, "max_results": {"type": "integer", "description": "Maximum events to return (default 50, max 500)", "default": 50} }, "required": ["spl"] } }
Token cost warning: 100 Splunk events can easily use 5,000-10,000 tokens. Keep max_results low (50-100). Tell the agent to use specific queries with | stats or | head rather than raw event searches. Cost optimisation: pre-filter to relevant fields before returning to the agent.
🔷
Microsoft Sentinel Tool — KQL via Azure Monitor API
Execute KQL queries in Microsoft Sentinel from your Python agent.
SIEMSentinel
▶
from azure.identity import DefaultAzureCredential from azure.monitor.query import LogsQueryClient from datetime import timedelta import json credential = DefaultAzureCredential() client = LogsQueryClient(credential) def query_sentinel(kql: str, workspace_id: str, timespan_hours: int = 24, max_rows: int = 100) -> dict: """ Execute KQL in Microsoft Sentinel Log Analytics workspace. Requires: azure-monitor-query, azure-identity packages. Auth: DefaultAzureCredential uses env vars, managed identity, or CLI login. """ try: response = client.query_workspace( workspace_id=workspace_id, query=kql + f" | limit {max_rows}", timespan=timedelta(hours=timespan_hours) ) rows = [] for table in response.tables: for row in table.rows: rows.append(dict(zip(table.columns, row))) return { "count": len(rows), "events": rows, "query": kql, "timespan_hours": timespan_hours } except Exception as e: return {"error": str(e), "count": 0, "events": []} SENTINEL_TOOL = { "name": "query_sentinel", "description": """Execute KQL to query Microsoft Sentinel security events. Use for: searching SecurityEvent, SigninLogs, AzureActivity, OfficeActivity tables. Always limit results with | limit N or | summarize before passing to me.""", "input_schema": { "type": "object", "properties": { "kql": {"type": "string", "description": "KQL query (without time filter — timespan set separately)"}, "timespan_hours": {"type": "integer", "description": "Hours to look back (default 24)", "default": 24}, "max_rows": {"type": "integer", "description": "Maximum rows to return (default 50)", "default": 50} }, "required": ["kql"] } } # Environment: set AZURE_WORKSPACE_ID WORKSPACE_ID = "your-log-analytics-workspace-id"
🌐
VirusTotal + AbuseIPDB Tool — IP / Domain / Hash Reputation
Multi-source threat intelligence lookup for IPs, domains, and file hashes.
Threat IntelIOC
▶
import requests import os VT_KEY = os.environ["VT_API_KEY"] # VirusTotal API key ABUSEIPDB_KEY = os.environ["ABUSEIPDB_KEY"] # AbuseIPDB API key def check_ip_reputation(ip: str) -> dict: """Check IP against VirusTotal and AbuseIPDB""" result = {"ip": ip, "sources": {}} # VirusTotal try: vt = requests.get( f"https://www.virustotal.com/api/v3/ip_addresses/{ip}", headers={"x-apikey": VT_KEY} ).json() attrs = vt.get("data", {}).get("attributes", {}) stats = attrs.get("last_analysis_stats", {}) result["virustotal"] = { "malicious": stats.get("malicious", 0), "suspicious": stats.get("suspicious", 0), "harmless": stats.get("harmless", 0), "country": attrs.get("country"), "asn": attrs.get("asn"), "as_owner": attrs.get("as_owner"), "tags": attrs.get("tags", []) } except Exception as e: result["virustotal"] = {"error": str(e)} # AbuseIPDB try: abuse = requests.get( "https://api.abuseipdb.com/api/v2/check", headers={"Key": ABUSEIPDB_KEY, "Accept": "application/json"}, params={"ipAddress": ip, "maxAgeInDays": 90} ).json() data = abuse.get("data", {}) result["abuseipdb"] = { "abuse_confidence": data.get("abuseConfidenceScore", 0), "total_reports": data.get("totalReports", 0), "country": data.get("countryCode"), "isp": data.get("isp"), "is_tor": data.get("isTor", False) } except Exception as e: result["abuseipdb"] = {"error": str(e)} # Derive verdict vt_mal = result.get("virustotal", {}).get("malicious", 0) abuse_conf = result.get("abuseipdb", {}).get("abuse_confidence", 0) is_tor = result.get("abuseipdb", {}).get("is_tor", False) if vt_mal >= 5 or abuse_conf >= 75 or is_tor: result["verdict"] = "MALICIOUS" result["confidence"] = 90 elif vt_mal >= 2 or abuse_conf >= 25: result["verdict"] = "SUSPICIOUS" result["confidence"] = 65 else: result["verdict"] = "CLEAN" result["confidence"] = 70 return result def check_file_hash(sha256: str) -> dict: """Check a file hash against VirusTotal""" try: vt = requests.get( f"https://www.virustotal.com/api/v3/files/{sha256}", headers={"x-apikey": VT_KEY} ).json() attrs = vt.get("data", {}).get("attributes", {}) stats = attrs.get("last_analysis_stats", {}) return { "hash": sha256, "malicious_detections": stats.get("malicious", 0), "total_engines": sum(stats.values()), "malware_family": attrs.get("popular_threat_classification", {}).get("suggested_threat_label"), "first_seen": attrs.get("first_submission_date"), "verdict": "MALICIOUS" if stats.get("malicious", 0) > 3 else "CLEAN" } except Exception as e: return {"hash": sha256, "error": str(e)} TI_TOOLS = [ { "name": "check_ip_reputation", "description": "Check an IP address against VirusTotal and AbuseIPDB threat intelligence. Returns malicious verdict, country, ASN, Tor status.", "input_schema": {"type": "object", "properties": {"ip": {"type": "string"}}, "required": ["ip"]} }, { "name": "check_file_hash", "description": "Check a SHA256 file hash against VirusTotal. Returns detection count, malware family name, and verdict.", "input_schema": {"type": "object", "properties": {"sha256": {"type": "string", "description": "SHA256 hash of the file"}}, "required": ["sha256"]} } ]
💬
Slack Notification with Human-in-the-Loop Approval
Send agent findings to Slack with Approve / Reject buttons for write actions.
NotificationsHITL
▶
import requests import os SLACK_WEBHOOK = os.environ["SLACK_WEBHOOK_URL"] SLACK_BOT_TOKEN = os.environ["SLACK_BOT_TOKEN"] SOC_CHANNEL = "#soc-alerts" def notify_analyst( alert_id: str, verdict: str, confidence: int, summary: str, evidence: list, proposed_action: str = None ) -> dict: """ Send agent triage result to Slack. If proposed_action is set, include Approve/Reject interactive buttons. This is the human-in-the-loop gate before any write actions. """ COLOURS = {"TRUE_POSITIVE": "#ef4444", "FALSE_POSITIVE": "#4db8a0", "NEEDS_INVESTIGATION": "#f59e0b"} colour = COLOURS.get(verdict, "#818cf8") blocks = [ {"type": "section", "text": {"type": "mrkdwn", "text": f"*🔔 AI Triage Result — Alert {alert_id}*"}}, {"type": "section", "fields": [ {"type": "mrkdwn", "text": f"*Verdict:*\n{verdict}"}, {"type": "mrkdwn", "text": f"*Confidence:*\n{confidence}%"} ]}, {"type": "section", "text": {"type": "mrkdwn", "text": f"*Summary:*\n{summary}"}}, {"type": "section", "text": {"type": "mrkdwn", "text": "*Evidence:*\n" + "\n".join(f"• {e}" for e in evidence[:5])}} ] # Add HITL approval buttons if a write action is proposed if proposed_action: blocks.append({ "type": "section", "text": {"type": "mrkdwn", "text": f"*⚠️ Proposed action:* `{proposed_action}`"} }) blocks.append({ "type": "actions", "elements": [ {"type": "button", "text": {"type": "plain_text", "text": "✅ Approve"}, "style": "primary", "action_id": "approve_action", "value": f"{alert_id}:{proposed_action}"}, {"type": "button", "text": {"type": "plain_text", "text": "❌ Reject"}, "style": "danger", "action_id": "reject_action", "value": f"{alert_id}:rejected"} ] }) response = requests.post(SLACK_WEBHOOK, json={ "channel": SOC_CHANNEL, "attachments": [{"color": colour, "blocks": blocks}] }) return {"sent": response.status_code == 200, "alert_id": alert_id} NOTIFY_TOOL = { "name": "notify_analyst", "description": "Send triage results to the SOC Slack channel. Use this as the FINAL step after completing investigation. Include proposed_action only if you recommend a specific containment action requiring analyst approval.", "input_schema": { "type": "object", "properties": { "alert_id": {"type": "string"}, "verdict": {"type": "string", "enum": ["TRUE_POSITIVE","FALSE_POSITIVE","NEEDS_INVESTIGATION"]}, "confidence": {"type": "integer", "minimum": 0, "maximum": 100}, "summary": {"type": "string"}, "evidence": {"type": "array", "items": {"type": "string"}}, "proposed_action": {"type": "string", "description": "Optional: action requiring analyst approval e.g. 'Block IP 185.220.101.45 on perimeter firewall'"} }, "required": ["alert_id", "verdict", "confidence", "summary", "evidence"] } }
Security principle: The agent can only NOTIFY via this tool — it cannot block IPs or take network actions. Actions only happen after an analyst clicks Approve in Slack. This is the critical human-in-the-loop gate. Never give an autonomous agent write access to firewall or EDR without this pattern.
🎫
Jira / ServiceNow — Auto-Create Incident Ticket
Create ITSM tickets with full investigation context from the agent's findings.
ITSMTicketing
▶
import requests import os from datetime import datetime JIRA_URL = os.environ["JIRA_URL"] # e.g. https://yourorg.atlassian.net JIRA_EMAIL = os.environ["JIRA_EMAIL"] JIRA_TOKEN = os.environ["JIRA_TOKEN"] JIRA_PROJECT = os.environ["JIRA_PROJECT"] # e.g. SOC JIRA_AUTH = (JIRA_EMAIL, JIRA_TOKEN) def create_security_ticket( summary: str, description: str, severity: str, alert_id: str, iocs: list = None, mitre_techniques: list = None ) -> dict: """ Create a Jira ticket for a security incident. Includes full investigation context from the agent. """ priority_map = {"CRITICAL": "Highest", "HIGH": "High", "MEDIUM": "Medium", "LOW": "Low"} priority = priority_map.get(severity.upper(), "Medium") # Build description with investigation context full_desc = f"""{description} --- *AI Triage Details* Alert ID: {alert_id} Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S IST')} *Indicators of Compromise* {chr(10).join(f'* {ioc}' for ioc in (iocs or []))} *MITRE ATT&CK Techniques* {chr(10).join(f'* {t}' for t in (mitre_techniques or []))} _This ticket was created by the AI Triage Agent. An analyst must review and validate before remediation._ """ payload = { "fields": { "project": {"key": JIRA_PROJECT}, "summary": f"[AI Triage] {summary}", "description": {"type": "doc", "version": 1, "content": [{"type": "paragraph", "content": [{"type": "text", "text": full_desc}]}]}, "issuetype": {"name": "Incident"}, "priority": {"name": priority}, "labels": ["ai-triage", "security", severity.lower()] } } resp = requests.post( f"{JIRA_URL}/rest/api/3/issue", json=payload, auth=JIRA_AUTH, headers={"Content-Type": "application/json"} ) if resp.status_code == 201: issue = resp.json() return {"success": True, "ticket_id": issue["key"], "url": f"{JIRA_URL}/browse/{issue['key']}"} else: return {"success": False, "error": resp.text} JIRA_TOOL = { "name": "create_security_ticket", "description": "Create a Jira security incident ticket with investigation findings. Use this for TRUE_POSITIVE verdicts to ensure proper tracking and follow-up. Do NOT create tickets for FALSE_POSITIVE alerts.", "input_schema": { "type": "object", "properties": { "summary": {"type": "string", "description": "One-line incident description"}, "description": {"type": "string", "description": "Full investigation summary"}, "severity": {"type": "string", "enum": ["CRITICAL","HIGH","MEDIUM","LOW"]}, "alert_id": {"type": "string"}, "iocs": {"type": "array", "items": {"type": "string"}}, "mitre_techniques": {"type": "array", "items": {"type": "string"}} }, "required": ["summary", "description", "severity", "alert_id"] } }
🛡️
CrowdStrike / Defender — Query Process and Network Data
Query EDR telemetry for process trees, network connections, and file events on specific hosts.
EDREndpoint
▶
import requests import os # CrowdStrike Falcon API CS_CLIENT_ID = os.environ["CS_CLIENT_ID"] CS_CLIENT_SECRET = os.environ["CS_CLIENT_SECRET"] CS_BASE_URL = "https://api.crowdstrike.com" def get_falcon_token() -> str: resp = requests.post(f"{CS_BASE_URL}/oauth2/token", data={"client_id": CS_CLIENT_ID, "client_secret": CS_CLIENT_SECRET}) return resp.json()["access_token"] def query_falcon_host(hostname: str) -> dict: """ Get host details and recent process activity from CrowdStrike Falcon. Read-only — no containment action without explicit human approval. """ token = get_falcon_token() headers = {"Authorization": f"Bearer {token}"} # Find device by hostname device_resp = requests.get(f"{CS_BASE_URL}/devices/queries/devices/v1", params={"filter": f"hostname:'{hostname}'"}, headers=headers).json() if not device_resp.get("resources"): return {"error": f"Host {hostname} not found in Falcon"} device_id = device_resp["resources"][0] device_detail = requests.get(f"{CS_BASE_URL}/devices/entities/devices/v2", params={"ids": device_id}, headers=headers).json() device = device_detail["resources"][0] return { "hostname": hostname, "device_id": device_id, "os": device.get("os_version"), "last_seen": device.get("last_seen"), "status": device.get("status"), "external_ip": device.get("external_ip"), "local_ip": device.get("local_ip"), "agent_version": device.get("agent_version") } # Microsoft Defender for Endpoint (MDE) via Graph API MDE_TENANT_ID = os.environ.get("MDE_TENANT_ID") MDE_CLIENT_ID = os.environ.get("MDE_CLIENT_ID") MDE_CLIENT_SECRET = os.environ.get("MDE_CLIENT_SECRET") def get_mde_token() -> str: resp = requests.post( f"https://login.microsoftonline.com/{MDE_TENANT_ID}/oauth2/v2.0/token", data={"client_id": MDE_CLIENT_ID, "client_secret": MDE_CLIENT_SECRET, "scope": "https://api.securitycenter.microsoft.com/.default", "grant_type": "client_credentials"} ) return resp.json()["access_token"] def query_mde_device(hostname: str) -> dict: """Query Microsoft Defender for Endpoint for host information""" token = get_mde_token() headers = {"Authorization": f"Bearer {token}"} resp = requests.get( "https://api.securitycenter.microsoft.com/api/machines", params={"$filter": f"computerDnsName eq '{hostname}'"}, headers=headers ).json() if not resp.get("value"): return {"error": f"Host {hostname} not found in MDE"} device = resp["value"][0] return { "hostname": hostname, "device_id": device.get("id"), "os": device.get("osPlatform"), "last_seen": device.get("lastSeen"), "risk_score": device.get("riskScore"), "exposure_level": device.get("exposureLevel"), "health_status": device.get("healthStatus"), "aad_device_id": device.get("aadDeviceId") } EDR_TOOLS = [ { "name": "query_falcon_host", "description": "Get CrowdStrike Falcon information for a specific host — OS, last seen, risk status. READ-ONLY.", "input_schema": {"type": "object", "properties": {"hostname": {"type": "string"}}, "required": ["hostname"]} }, { "name": "query_mde_device", "description": "Get Microsoft Defender for Endpoint information for a host — risk score, exposure level, health status. READ-ONLY.", "input_schema": {"type": "object", "properties": {"hostname": {"type": "string"}}, "required": ["hostname"]} } ]
Important: These patterns are READ-ONLY queries. Do not expose containment actions (isolate_host, run_script) as agent tools without a human-in-the-loop approval gate. An agent that can autonomously isolate hosts based on a potentially injected alert is a serious operational risk.