diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..fe68ee4
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,5 @@
+# Chronicle API SDK Configuration
+CHRONICLE_CREDENTIALS_FILE=path/to/credentials.json
+CHRONICLE_PROJECT_ID=your-project-id
+CHRONICLE_INSTANCE=your-instance-id
+CHRONICLE_REGION=your-region
diff --git a/.gitignore b/.gitignore
index 4816a54..0b89610 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,48 @@ __pycache__/
 venv/
 
 node_modules/
+
+# Python
+__pycache__/
+*.py[cod]
+*$py.class
+*.so
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# Virtual Environment
+venv/
+env/
+ENV/
+
+# IDE
+.idea/
+.vscode/
+*.swp
+*.swo
+
+# Environment Variables
+.env
+.env.*
+!.env.example
+
+# Credentials
+*credentials*.json
+*creds*.json
+
+# Logs
+*.log
diff --git a/.style.yapf b/.style.yapf
new file mode 100644
index 0000000..a72817d
--- /dev/null
+++ b/.style.yapf
@@ -0,0 +1,4 @@
+[style]
+based_on_style = google
+indent_width = 2
+column_limit = 80
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..775a1c2
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,13 @@
+.PHONY: install dist clean
+
+build:
+	python -m build
+
+install:
+	python setup.py install
+
+dist:
+	python setup.py bdist_wheel
+
+clean:
+	rm -rf build/ dist/ *.egg-info/
diff --git a/README.md b/README.md
index e6c0055..8703346 100644
--- a/README.md
+++ b/README.md
@@ -30,15 +30,7 @@ samples try to use the file `.chronicle_credentials.json` in the user's home
 directory. If this file is not found, you need to specify it explicitly by
 adding the following argument to the sample's command-line:
 
-```shell
--c <file_path>
-```
-
-or
-
-```shell
---credentials_file <file_path>
-```
+`shell -c <file_path>` or `shell --credentials_file <file_path>`
 
 ## Usage
 
@@ -60,8 +52,166 @@ python3 -m lists.<sample_name> -h
 
 ### Lists API v1alpha
 
-```
+```shell
 python -m lists.v1alpha.create_list -h
 python -m lists.v1alpha.get_list -h
 python -m lists.v1alpha.patch_list -h
 ```
+
+## Installing the Chronicle REST API SDK
+
+Install the SDK from source
+```
+python setup.py install
+```
+
+Alternatively, install the SDK from source using make
+```
+make install
+```
+
+Build the wheel file
+```
+make dist
+```
+
+## Using the Chronicle REST API SDK
+
+The SDK provides a unified command-line interface for Chronicle APIs.
+The CLI follows this pattern:
+```
+chronicle [common options] COMMAND_GROUP COMMAND [command options]
+```
+
+### Common Options
+
+Common options can be provided either via command-line arguments or environment
+variables:
+
+| CLI Option         | Environment Variable        | Description                   |
+|--------------------|----------------------------|--------------------------------|
+| --credentials-file | CHRONICLE_CREDENTIALS_FILE | Path to service account file   |
+| --project-id       | CHRONICLE_PROJECT_ID       | GCP project id or number       |
+| --project-instance | CHRONICLE_INSTANCE         | Chronicle instance ID (uuid)   |
+| --region           | CHRONICLE_REGION           | Region where project is located|
+
+You can set these options in a `.env` file in your project root:
+
+```bash
+# .env file
+CHRONICLE_CREDENTIALS_FILE=path/to/credentials.json
+CHRONICLE_PROJECT_ID=your-project-id
+CHRONICLE_INSTANCE=your-instance-id
+CHRONICLE_REGION=your-region
+```
+
+The SDK will use values from the `.env` file or a file provided with the
+`--env-file` parameter. Command-line options take precedence over environment
+variables.
+
+### Command Groups
+
+#### Detection API
+```bash
+chronicle detect <command-group> <command> [options]
+```
+
+Available command groups:
+
+- `alerts`
+  - `get <alert-id>`: Get alert by ID
+  - `update <alert-id>`: Update an alert
+  - `bulk-update`: Bulk update alerts matching a filter
+
+- `detections`
+  - `get <detection-id>`: Get detection by ID
+  - `list [--filter <filter>]`: List detections
+
+- `rules`
+  - `create`: Create a new rule
+  - `get <rule-id>`: Get rule by ID
+  - `delete <rule-id>`: Delete a rule
+  - `enable <rule-id>`: Enable a rule
+  - `list [--filter <filter>]`: List rules
+
+- `retrohunts`
+  - `create`: Create a new retrohunt
+  - `get <retrohunt-id>`: Get retrohunt by ID
+
+- `errors`
+  - `list [--filter <filter>]`: List errors
+
+- `rulesets`
+  - `batch-update`: Batch update rule set deployments
+
+#### Ingestion API
+```bash
+chronicle ingestion <command> [options]
+```
+
+Available commands:
+
+- `import-events`: Import events into Chronicle
+- `get-event <event-id>`: Get event details
+- `batch-get-events`: Batch retrieve events
+
+#### Search API
+```bash
+chronicle search <command> [options]
+```
+
+Available commands:
+
+- `find-asset-events [--filter <filter>]`: Find events for an asset
+- `find-raw-logs [--filter <filter>]`: Search raw logs
+- `find-udm-events [--filter <filter>]`: Find UDM events
+
+#### Lists API
+```bash
+chronicle lists <command> [options]
+```
+
+Available commands:
+
+- `create <name> [--description <desc>] --lines <json-array>`: Create a new list
+- `get <list-id>`: Get list by ID
+- `patch <list-id> [--description <desc>]
+  [--lines-to-add <json-array>] \
+  [--lines-to-remove <json-array>]`: Update an existing list
+
+### Examples
+
+Using environment variables (after setting up .env):
+```bash
+# Get an alert
+chronicle detect alerts get --alert-id ABC123 --env-file=.env
+
+# Create a list
+chronicle lists create --name "blocklist" --description "Blocked IPs" \
+ --lines '["1.1.1.1", "2.2.2.2"]' \
+ --env-file=.env
+
+# Search for events
+chronicle search find-raw-logs --filter "timestamp.seconds > 1600000000" \
+ --env-file=.env
+
+# Override a specific environment variable
+chronicle --region us-central1 detect alerts get --alert-id ABC123 \
+ --env-file=.env
+```
+
+## Running Individual Scripts
+
+You can also run individual API sample scripts directly.
+Each script supports the `-h` flag to show available options:
+
+```bash
+# Get help for a specific script
+python -m detect.v1alpha.get_alert -h
+python -m search.v1alpha.find_asset_events -h
+python -m lists.v1alpha.patch_list -h
+```
+
+## License
+
+Apache 2.0 - See [LICENSE](LICENSE) for more information.
diff --git a/__init__.py b/__init__.py
new file mode 100644
index 0000000..3d87ee0
--- /dev/null
+++ b/__init__.py
@@ -0,0 +1,14 @@
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
diff --git a/chronicle_api.egg-info/PKG-INFO b/chronicle_api.egg-info/PKG-INFO
new file mode 100644
index 0000000..9c9ba25
--- /dev/null
+++ b/chronicle_api.egg-info/PKG-INFO
@@ -0,0 +1,19 @@
+Metadata-Version: 2.2
+Name: chronicle-api
+Version: 0.1.3
+Summary: Chronicle API SDK and CLI
+Author: Google LLC
+Author-email: chronicle-support@google.com
+License: Apache 2.0
+Requires-Python: >=3.10
+License-File: LICENSE
+Requires-Dist: click>=8.0.0
+Requires-Dist: google-auth>=2.0.0
+Requires-Dist: requests>=2.25.0
+Requires-Dist: python-dotenv>=1.0.0
+Dynamic: author
+Dynamic: author-email
+Dynamic: license
+Dynamic: requires-dist
+Dynamic: requires-python
+Dynamic: summary
diff --git a/chronicle_api.egg-info/SOURCES.txt b/chronicle_api.egg-info/SOURCES.txt
new file mode 100644
index 0000000..0eff402
--- /dev/null
+++ b/chronicle_api.egg-info/SOURCES.txt
@@ -0,0 +1,66 @@
+LICENSE
+README.md
+pyproject.toml
+setup.py
+chronicle_api.egg-info/PKG-INFO
+chronicle_api.egg-info/SOURCES.txt
+chronicle_api.egg-info/dependency_links.txt
+chronicle_api.egg-info/entry_points.txt
+chronicle_api.egg-info/requires.txt
+chronicle_api.egg-info/top_level.txt
+common/__init__.py
+common/chronicle_auth.py
+common/chronicle_auth_test.py
+common/datetime_converter.py
+common/datetime_converter_test.py
+common/project_id.py
+common/project_instance.py
+common/regions.py
+common/regions_test.py
+detect/v1alpha/__init__.py
+detect/v1alpha/batch_update_curated_rule_set_deployments.py
+detect/v1alpha/bulk_update_alerts.py
+detect/v1alpha/create_retrohunt.py
+detect/v1alpha/create_rule.py
+detect/v1alpha/delete_rule.py
+detect/v1alpha/enable_rule.py
+detect/v1alpha/get_alert.py
+detect/v1alpha/get_detection.py
+detect/v1alpha/get_retrohunt.py
+detect/v1alpha/get_rule.py
+detect/v1alpha/list_detections.py
+detect/v1alpha/list_errors.py
+detect/v1alpha/list_rules.py
+detect/v1alpha/update_alert.py
+detect/v1alpha/update_rule.py
+ingestion/v1alpha/__init__.py
+ingestion/v1alpha/create_udm_events.py
+ingestion/v1alpha/event_import.py
+ingestion/v1alpha/events_batch_get.py
+ingestion/v1alpha/events_get.py
+ingestion/v1alpha/get_udm_event.py
+iocs/v1alpha/__init__.py
+iocs/v1alpha/batch_get_iocs.py
+iocs/v1alpha/get_ioc.py
+iocs/v1alpha/get_ioc_state.py
+lists/v1alpha/__init__.py
+lists/v1alpha/create_list.py
+lists/v1alpha/get_list.py
+lists/v1alpha/patch_list.py
+lists/v1alpha/patch_list_test.py
+sdk/__init__.py
+sdk/cli.py
+sdk/commands/__init__.py
+sdk/commands/common.py
+sdk/commands/detect.py
+sdk/commands/ingestion.py
+sdk/commands/iocs.py
+sdk/commands/lists.py
+sdk/commands/search.py
+search/v1alpha/__init__.py
+search/v1alpha/asset_events_find.py
+search/v1alpha/client.py
+search/v1alpha/raw_logs_find.py
+search/v1alpha/search_queries_list.py
+search/v1alpha/search_query_get.py
+search/v1alpha/udm_events_find.py
\ No newline at end of file
diff --git a/chronicle_api.egg-info/dependency_links.txt b/chronicle_api.egg-info/dependency_links.txt
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/chronicle_api.egg-info/dependency_links.txt
@@ -0,0 +1 @@
+
diff --git a/chronicle_api.egg-info/entry_points.txt b/chronicle_api.egg-info/entry_points.txt
new file mode 100644
index 0000000..ae3f328
--- /dev/null
+++ b/chronicle_api.egg-info/entry_points.txt
@@ -0,0 +1,2 @@
+[console_scripts]
+chronicle = sdk.cli:cli
diff --git a/chronicle_api.egg-info/requires.txt b/chronicle_api.egg-info/requires.txt
new file mode 100644
index 0000000..04b6a7d
--- /dev/null
+++ b/chronicle_api.egg-info/requires.txt
@@ -0,0 +1,4 @@
+click>=8.0.0
+google-auth>=2.0.0
+requests>=2.25.0
+python-dotenv>=1.0.0
diff --git a/chronicle_api.egg-info/top_level.txt b/chronicle_api.egg-info/top_level.txt
new file mode 100644
index 0000000..522a892
--- /dev/null
+++ b/chronicle_api.egg-info/top_level.txt
@@ -0,0 +1,7 @@
+common
+detect
+ingestion
+iocs
+lists
+sdk
+search
diff --git a/common/__init__.py b/common/__init__.py
index af1d471..3d87ee0 100644
--- a/common/__init__.py
+++ b/common/__init__.py
@@ -1,4 +1,4 @@
-# Copyright 2021 Google LLC
+# Copyright 2025 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
diff --git a/common/regions.py b/common/regions.py
index 9c71bbb..18f67d1 100644
--- a/common/regions.py
+++ b/common/regions.py
@@ -20,6 +20,7 @@
 import argparse
 
 REGION_LIST = (
+    "africa-south1",
     "asia-northeast1",
     "asia-south1",
     "asia-southeast1",
diff --git a/detect/__init__.py b/detect/__init__.py
new file mode 100644
index 0000000..3d87ee0
--- /dev/null
+++ b/detect/__init__.py
@@ -0,0 +1,14 @@
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
diff --git a/detect/v1alpha/__init__.py b/detect/v1alpha/__init__.py
index 1ee14b7..3d87ee0 100644
--- a/detect/v1alpha/__init__.py
+++ b/detect/v1alpha/__init__.py
@@ -1,4 +1,4 @@
-# Copyright 2024 Google LLC
+# Copyright 2025 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
diff --git a/detect/v1alpha/batch_update_curated_rule_set_deployments.py b/detect/v1alpha/batch_update_curated_rule_set_deployments.py
index abc9ac3..bf6fd7a 100644
--- a/detect/v1alpha/batch_update_curated_rule_set_deployments.py
+++ b/detect/v1alpha/batch_update_curated_rule_set_deployments.py
@@ -1,6 +1,6 @@
 #!/usr/bin/env python3
 
-# Copyright 2024 Google LLC
+# Copyright 2025 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -14,20 +14,34 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 #
-r"""Executable sample for batch updating curated rule sets deployments.
-
-Sample Commands (run from api_samples_python dir):
-    # Modify the script to update the constants that point to deployments.
-    python3 -m detect.v1alpha.batch_update_curated_rule_set_deployments \
-        -r=<region> -p=<project_id> -i=<instance_id>
+# pylint: disable=line-too-long
+r"""Executable and reusable v1alpha API sample for batch updating curated rule set deployments.
+
+Usage:
+  python -m detect.v1alpha.batch_update_curated_rule_set_deployments \
+    --project_id=<PROJECT_ID> \
+    --project_instance=<PROJECT_INSTANCE> \
+    --region=<REGION>
+
+  # The script contains example category/rule_set/precision IDs that need to be updated:
+  # Category A: aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa
+  # Rule Set A: bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb
+  # Precision A: broad
+  #
+  # Category B: cccccccc-cccc-cccc-cccc-cccccccccccc
+  # Rule Set B: dddddddd-dddd-dddd-dddd-dddddddddddd
+  # Precision B: precise
 
 API reference:
-    https://cloud.google.com/chronicle/docs/reference/rest/v1alpha/projects.locations.instances.curatedRuleSetCategories.curatedRuleSets.curatedRuleSetDeployments/batchUpdate
-    https://cloud.google.com/chronicle/docs/reference/rest/v1alpha/projects.locations.instances.curatedRuleSetCategories.curatedRuleSets.curatedRuleSetDeployments#CuratedRuleSetDeployment
+https://cloud.google.com/chronicle/docs/reference/rest/v1alpha/projects.locations.instances.curatedRuleSetCategories.curatedRuleSets.curatedRuleSetDeployments/batchUpdate
+https://cloud.google.com/chronicle/docs/reference/rest/v1alpha/projects.locations.instances.curatedRuleSetCategories.curatedRuleSets.curatedRuleSetDeployments#CuratedRuleSetDeployment
 """
+# pylint: enable=line-too-long
+
 import argparse
 import json
 from typing import Any, Mapping
+
 from common import chronicle_auth
 from common import project_id
 from common import project_instance
@@ -35,7 +49,6 @@
 from google.auth.transport import requests
 
 CHRONICLE_API_BASE_URL = "https://chronicle.googleapis.com"
-
 SCOPES = [
     "https://www.googleapis.com/auth/cloud-platform",
 ]
@@ -43,74 +56,73 @@
 
 def batch_update_curated_rule_set_deployments(
     http_session: requests.AuthorizedSession,
-    proj_region: str,
     proj_id: str,
     proj_instance: str,
+    proj_region: str,
 ) -> Mapping[str, Any]:
-  """Batch update curated rule set deployments.
+  """Batch updates multiple curated rule set deployments.
 
   Args:
     http_session: Authorized session for HTTP requests.
-    proj_region: region in which the target project is located
-    proj_id: GCP project id or number which the target instance belongs to
-    proj_instance: uuid of the instance (with dashes)
+    proj_id: GCP project id or number to which the target instance belongs.
+    proj_instance: Customer ID (uuid with dashes) for the Chronicle instance.
+    proj_region: region in which the target project is located.
 
   Returns:
-    an object with information about the modified deployments
+    Dictionary containing information about the modified deployments.
 
   Raises:
     requests.exceptions.HTTPError: HTTP request resulted in an error
       (response.status_code >= 400).
-  """
 
+  Requires the following IAM permission on the parent resource:
+  chronicle.curatedRuleSetDeployments.update
+  """
   base_url_with_region = regions.url_always_prepend_region(
-      CHRONICLE_API_BASE_URL,
-      args.region
-  )
+      CHRONICLE_API_BASE_URL, proj_region)
   # pylint: disable-next=line-too-long
   parent = f"projects/{proj_id}/locations/{proj_region}/instances/{proj_instance}"
 
   # We use "-" in the URL because we provide category and rule_set IDs
-  # in the request data.
+  # in the request data
+  # pylint: disable-next=line-too-long
   url = f"{base_url_with_region}/v1alpha/{parent}/curatedRuleSetCategories/-/curatedRuleSets/-/curatedRuleSetDeployments:batchUpdate"
 
-  # Helper function for making a deployment name. Use this as the
-  # curated_rule_set_deployment.name field in the request data below.
-  def make_deployment_name(category, rule_set, precision):
+  def make_deployment_name(category: str, rule_set: str, precision: str) -> str:
+    """Helper function to create a deployment name."""
+    # pylint: disable-next=line-too-long
     return f"{parent}/curatedRuleSetCategories/{category}/curatedRuleSets/{rule_set}/curatedRuleSetDeployments/{precision}"
 
-  # Note that IDs are hard-coded below, as examples.
-  print("\nCategories, rule sets, and precisions are hard-coded as " +
-        "examples. Update the script to provide actual IDs.\n"
-        )
-
-  # Modify the category/rule_set/precision for each deployment below.
-  # Deployment A.
+  # Example deployment configurations - update these with actual IDs
+  # Deployment A
   category_a = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
   rule_set_a = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
   precision_a = "broad"
 
-  # Deployment B.
+  # Deployment B
   category_b = "cccccccc-cccc-cccc-cccc-cccccccccccc"
   rule_set_b = "dddddddd-dddd-dddd-dddd-dddddddddddd"
   precision_b = "precise"
 
-  # Modify the data below to change the behavior of the request.
-  # - Add elements to `requests` to batch update multiple deployments
-  # - Change the enabled and alerting fields as needed
-  # - Change the update_mask to modify only certain properties
+  print("\nNOTE: Using example category/rule_set/precision IDs.")
+  print("Please update the script with actual IDs before use.\n")
+
   json_data = {
-      "parent": f"{parent}/curatedRuleSetCategories/-/curatedRuleSets/-",
+      "parent":
+          f"{parent}/curatedRuleSetCategories/-/curatedRuleSets/-",
       "requests": [
           {
               "curated_rule_set_deployment": {
-                  "name": make_deployment_name(
-                      category_a,
-                      rule_set_a,
-                      precision_a,
-                  ),
-                  "enabled": True,
-                  "alerting": False,
+                  "name":
+                      make_deployment_name(
+                          category_a,
+                          rule_set_a,
+                          precision_a,
+                      ),
+                  "enabled":
+                      True,
+                  "alerting":
+                      False,
               },
               "update_mask": {
                   "paths": ["alerting", "enabled"],
@@ -118,51 +130,46 @@ def make_deployment_name(category, rule_set, precision):
           },
           {
               "curated_rule_set_deployment": {
-                  "name": make_deployment_name(
-                      category_b,
-                      rule_set_b,
-                      precision_b,
-                  ),
-                  "enabled": True,
-                  "alerting": True,
+                  "name":
+                      make_deployment_name(
+                          category_b,
+                          rule_set_b,
+                          precision_b,
+                      ),
+                  "enabled":
+                      True,
+                  "alerting":
+                      True,
               },
               "update_mask": {
                   "paths": ["alerting", "enabled"],
               },
           },
       ],
-    }
+  }
 
-  # See API reference links at top of this file, for response format.
   response = http_session.request("POST", url, json=json_data)
-
   if response.status_code >= 400:
     print(response.text)
   response.raise_for_status()
+
   return response.json()
 
 
 if __name__ == "__main__":
   parser = argparse.ArgumentParser()
+  # common
   chronicle_auth.add_argument_credentials_file(parser)
-  regions.add_argument_region(parser)
-  project_instance.add_argument_project_instance(parser)
   project_id.add_argument_project_id(parser)
+  project_instance.add_argument_project_instance(parser)
+  regions.add_argument_region(parser)
 
   args = parser.parse_args()
-  auth_session = chronicle_auth.initialize_http_session(
-      args.credentials_file,
-      SCOPES
-  )
-  session = chronicle_auth.initialize_http_session(args.credentials_file)
-  print(
-      json.dumps(
-          batch_update_curated_rule_set_deployments(
-              auth_session,
-              args.region,
-              args.project_id,
-              args.project_instance,
-          ),
-          indent=2,
-      )
-  )
+
+  auth_session = chronicle_auth.initialize_http_session(args.credentials_file,
+                                                        SCOPES)
+  result = batch_update_curated_rule_set_deployments(auth_session,
+                                                     args.project_id,
+                                                     args.project_instance,
+                                                     args.region)
+  print(json.dumps(result, indent=2))
diff --git a/detect/v1alpha/bulk_update_alerts.py b/detect/v1alpha/bulk_update_alerts.py
index 705dd77..321ee97 100644
--- a/detect/v1alpha/bulk_update_alerts.py
+++ b/detect/v1alpha/bulk_update_alerts.py
@@ -16,7 +16,7 @@
 #
 r"""Executable and reusable sample for bulk updating alerts.
 
-The file provided to the --alert_ids_file parameter should have one alert 
+The file provided to the --alert_ids_file parameter should have one alert
  ID per line like so:
 ```
 de_ad9d2771-a567-49ee-6452-1b2db13c1d33
diff --git a/detect/v1alpha/create_retrohunt.py b/detect/v1alpha/create_retrohunt.py
index 6215209..f5ed153 100644
--- a/detect/v1alpha/create_retrohunt.py
+++ b/detect/v1alpha/create_retrohunt.py
@@ -1,6 +1,6 @@
 #!/usr/bin/env python3
 
-# Copyright 2024 Google LLC
+# Copyright 2025 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -14,21 +14,29 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 #
-r"""Executable sample for creating a retrohunt.
+# pylint: disable=line-too-long
+r"""Executable and reusable v1alpha API sample for creating a retrohunt.
 
-Sample Commands (run from api_samples_python dir):
-    python3 -m detect.v1alpha.create_retrohunt \
-        -r=<region> -p=<project_id> -i=<instance_id> -rid=<rule_id> \
-        -st="2023-10-02T18:00:00Z" -et="2023-10-02T20:00:00Z"
+Usage:
+  python -m detect.v1alpha.create_retrohunt \
+    --project_id=<PROJECT_ID> \
+    --project_instance=<PROJECT_INSTANCE> \
+    --region=<REGION> \
+    --rule_id=ru_<UUID> \
+    --start_time=2023-10-02T18:00:00Z \
+    --end_time=2023-10-02T20:00:00Z
 
 API reference:
-    https://cloud.google.com/chronicle/docs/reference/rest/v1alpha/projects.locations.instances.rules.retrohunts/create
-    https://cloud.google.com/chronicle/docs/reference/rest/v1alpha/projects.locations.instances.operations#Operation
+https://cloud.google.com/chronicle/docs/reference/rest/v1alpha/projects.locations.instances.rules.retrohunts/create
+https://cloud.google.com/chronicle/docs/reference/rest/v1alpha/projects.locations.instances.operations#Operation
 """
+# pylint: enable=line-too-long
+
 import argparse
 import datetime
 import json
 from typing import Any, Mapping
+
 from common import chronicle_auth
 from common import datetime_converter
 from common import project_id
@@ -37,7 +45,6 @@
 from google.auth.transport import requests
 
 CHRONICLE_API_BASE_URL = "https://chronicle.googleapis.com"
-
 SCOPES = [
     "https://www.googleapis.com/auth/cloud-platform",
 ]
@@ -45,40 +52,41 @@
 
 def create_retrohunt(
     http_session: requests.AuthorizedSession,
-    proj_region: str,
     proj_id: str,
     proj_instance: str,
+    proj_region: str,
     rule_id: str,
     start_time: datetime.datetime,
     end_time: datetime.datetime,
 ) -> Mapping[str, Any]:
-  """Creates a retrohunt.
+  """Creates a retrohunt to run a detection rule over historical data.
 
   Args:
     http_session: Authorized session for HTTP requests.
-    proj_region: region in which the target project is located
-    proj_id: GCP project id or number which the target instance belongs to
-    proj_instance: uuid of the instance (with dashes)
-    rule_id: Unique ID of the detection rule to retrieve ("ru_<UUID>").
-    start_time: the start time of the event time range this retrohunt will be
-      executed over
-    end_time: the end time of the event time range this retrohunt will be
-      executed over
+    proj_id: GCP project id or number to which the target instance belongs.
+    proj_instance: Customer ID (uuid with dashes) for the Chronicle instance.
+    proj_region: region in which the target project is located.
+    rule_id: Unique ID of the detection rule to run (in the format "ru_<UUID>").
+    start_time: Start time of the event time range for the retrohunt.
+    end_time: End time of the event time range for the retrohunt.
 
   Returns:
-    an Operation resource object containing relevant retrohunt's information
+    Dictionary containing the Operation resource for the retrohunt.
 
   Raises:
     requests.exceptions.HTTPError: HTTP request resulted in an error
       (response.status_code >= 400).
+
+  Requires the following IAM permission on the parent resource:
+  chronicle.retrohunts.create
   """
   base_url_with_region = regions.url_always_prepend_region(
-      CHRONICLE_API_BASE_URL,
-      args.region
-  )
-  # pylint: disable-next=line-too-long
+      CHRONICLE_API_BASE_URL, proj_region)
+  # pylint: disable=line-too-long
   parent = f"projects/{proj_id}/locations/{proj_region}/instances/{proj_instance}"
   url = f"{base_url_with_region}/v1alpha/{parent}/rules/{rule_id}/retrohunts"
+  # pylint: enable=line-too-long
+
   body = {
       "process_interval": {
           "start_time": datetime_converter.strftime(start_time),
@@ -86,57 +94,41 @@ def create_retrohunt(
       },
   }
 
-  # See API reference links at top of this file, for response format.
   response = http_session.request("POST", url, json=body)
   if response.status_code >= 400:
     print(response.text)
   response.raise_for_status()
+
   return response.json()
 
 
 if __name__ == "__main__":
   parser = argparse.ArgumentParser()
+  # common
   chronicle_auth.add_argument_credentials_file(parser)
-  regions.add_argument_region(parser)
   project_instance.add_argument_project_instance(parser)
   project_id.add_argument_project_id(parser)
+  regions.add_argument_region(parser)
+  # local
   parser.add_argument(
-      "-rid",
       "--rule_id",
       type=str,
       required=True,
-      help='rule ID to create retrohunt for. In the form of "ru_<UUID>"',
-  )
-  parser.add_argument(
-      "-st",
-      "--start_time",
-      type=datetime_converter.iso8601_datetime_utc,
-      required=True,
-      help="Retrohunt start time in UTC ('yyyy-mm-ddThh:mm:ssZ')",
-  )
-  parser.add_argument(
-      "-et",
-      "--end_time",
-      type=datetime_converter.iso8601_datetime_utc,
-      required=True,
-      help="Retrohunt end time in UTC ('yyyy-mm-ddThh:mm:ssZ')",
-  )
+      help='ID of rule to create retrohunt for (format: "ru_<UUID>")')
+  parser.add_argument("--start_time",
+                      type=datetime_converter.iso8601_datetime_utc,
+                      required=True,
+                      help="Start time in UTC (format: yyyy-mm-ddThh:mm:ssZ)")
+  parser.add_argument("--end_time",
+                      type=datetime_converter.iso8601_datetime_utc,
+                      required=True,
+                      help="End time in UTC (format: yyyy-mm-ddThh:mm:ssZ)")
+
   args = parser.parse_args()
-  auth_session = chronicle_auth.initialize_http_session(
-      args.credentials_file,
-      SCOPES
-  )
-  print(
-      json.dumps(
-          create_retrohunt(
-              auth_session,
-              args.region,
-              args.project_id,
-              args.project_instance,
-              args.rule_id,
-              args.start_time,
-              args.end_time,
-          ),
-          indent=2,
-      )
-  )
+
+  auth_session = chronicle_auth.initialize_http_session(args.credentials_file,
+                                                        SCOPES)
+  result = create_retrohunt(auth_session, args.project_id,
+                            args.project_instance, args.region, args.rule_id,
+                            args.start_time, args.end_time)
+  print(json.dumps(result, indent=2))
diff --git a/detect/v1alpha/create_rule.py b/detect/v1alpha/create_rule.py
index 9bfa13c..c9dd971 100644
--- a/detect/v1alpha/create_rule.py
+++ b/detect/v1alpha/create_rule.py
@@ -1,6 +1,6 @@
 #!/usr/bin/env python3
 
-# Copyright 2024 Google LLC
+# Copyright 2025 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -14,27 +14,28 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 #
-r"""Executable and reusable sample for creating a detection rule.
-
-Sample Commands (run from api_samples_python dir):
-    # From file
-    python3 -m detect.v1alpha.create_rule \
-        --region $region \
-        --project_instance $project_instance \
-        --project_id $PROJECT_ID \
-        --rule_file=./path/to/rule/rulename.yaral
-
-    # From stdin
-    cat ./path/rulename.yaral | python3 -m detect.v1alpha.create_rule \
-        --region $region \
-        --project_instance $project_instance \
-        --project_id $PROJECT_ID \
-        --rule_file -
+# pylint: disable=line-too-long
+r"""Executable and reusable v1alpha API sample for creating a detection rule.
+
+Usage:
+  python -m detect.v1alpha.create_rule \
+    --project_id=<PROJECT_ID> \
+    --project_instance=<PROJECT_INSTANCE> \
+    --region=<REGION> \
+    --rule_file=./path/to/rule/rulename.yaral
+
+  # Or from stdin:
+  cat ./path/rulename.yaral | python -m detect.v1alpha.create_rule \
+    --project_id=<PROJECT_ID> \
+    --project_instance=<PROJECT_INSTANCE> \
+    --region=<REGION> \
+    --rule_file=-
 
 API reference:
-  https://cloud.google.com/chronicle/docs/reference/rest/v1alpha/projects.locations.instances.rules/create
-  https://cloud.google.com/chronicle/docs/reference/rest/v1alpha/projects.locations.instances.rules#Rule
+https://cloud.google.com/chronicle/docs/reference/rest/v1alpha/projects.locations.instances.rules/create
+https://cloud.google.com/chronicle/docs/reference/rest/v1alpha/projects.locations.instances.rules#Rule
 """
+# pylint: enable=line-too-long
 
 import argparse
 import json
@@ -47,7 +48,6 @@
 from google.auth.transport import requests
 
 CHRONICLE_API_BASE_URL = "https://chronicle.googleapis.com"
-
 SCOPES = [
     "https://www.googleapis.com/auth/cloud-platform",
 ]
@@ -70,16 +70,17 @@ def create_rule(
     rule_file_path: Content of the new detection rule, used to evaluate logs.
 
   Returns:
-    New detection rule.
+    Dictionary containing the newly created detection rule.
 
   Raises:
     requests.exceptions.HTTPError: HTTP request resulted in an error
       (response.status_code >= 400).
+
+  Requires the following IAM permission on the parent resource:
+  chronicle.rules.create
   """
   base_url_with_region = regions.url_always_prepend_region(
-      CHRONICLE_API_BASE_URL,
-      args.region
-  )
+      CHRONICLE_API_BASE_URL, proj_region)
   # pylint: disable-next=line-too-long
   parent = f"projects/{proj_id}/locations/{proj_region}/instances/{proj_instance}"
   url = f"{base_url_with_region}/v1alpha/{parent}/rules"
@@ -88,11 +89,11 @@ def create_rule(
       "text": rule_file_path.read(),
   }
 
-  # See API reference links at top of this file, for response format.
   response = http_session.request("POST", url, json=body)
   if response.status_code >= 400:
     print(response.text)
   response.raise_for_status()
+
   return response.json()
 
 
@@ -105,21 +106,15 @@ def create_rule(
   regions.add_argument_region(parser)
   # local
   parser.add_argument(
-      "-f",
       "--rule_file",
       type=argparse.FileType("r"),
       required=True,
-      help="path of a file with the desired rule's content, or - for STDIN",
-  )
+      help="Path to file containing the rule content, or - for STDIN")
+
   args = parser.parse_args()
 
-  auth_session = chronicle_auth.initialize_http_session(
-      args.credentials_file,
-      SCOPES
-  )
-  new_rule = create_rule(auth_session,
-                         args.project_id,
-                         args.project_instance,
-                         args.region,
-                         args.rule_file)
+  auth_session = chronicle_auth.initialize_http_session(args.credentials_file,
+                                                        SCOPES)
+  new_rule = create_rule(auth_session, args.project_id, args.project_instance,
+                         args.region, args.rule_file)
   print(json.dumps(new_rule, indent=2))
diff --git a/detect/v1alpha/delete_rule.py b/detect/v1alpha/delete_rule.py
index cffb068..b7406fe 100644
--- a/detect/v1alpha/delete_rule.py
+++ b/detect/v1alpha/delete_rule.py
@@ -1,6 +1,6 @@
 #!/usr/bin/env python3
 
-# Copyright 2024 Google LLC
+# Copyright 2025 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -66,9 +66,7 @@ def delete_rule(
       (response.status_code >= 400).
   """
   base_url_with_region = regions.url_always_prepend_region(
-      CHRONICLE_API_BASE_URL,
-      args.region
-  )
+      CHRONICLE_API_BASE_URL, args.region)
   # pylint: disable-next=line-too-long
   parent = f"projects/{proj_id}/locations/{proj_region}/instances/{proj_instance}"
   url = f"{base_url_with_region}/v1alpha/{parent}/rules/{rule_id}"
@@ -95,10 +93,8 @@ def delete_rule(
       help='ID of rule to be deleted. In the form of "ru_<UUID>"',
   )
   args = parser.parse_args()
-  auth_session = chronicle_auth.initialize_http_session(
-      args.credentials_file,
-      SCOPES
-  )
+  auth_session = chronicle_auth.initialize_http_session(args.credentials_file,
+                                                        SCOPES)
   print(
       json.dumps(
           delete_rule(
@@ -109,5 +105,4 @@ def delete_rule(
               args.rule_id,
           ),
           indent=2,
-      )
-  )
+      ))
diff --git a/detect/v1alpha/enable_rule.py b/detect/v1alpha/enable_rule.py
index 5323327..2b9e440 100644
--- a/detect/v1alpha/enable_rule.py
+++ b/detect/v1alpha/enable_rule.py
@@ -1,6 +1,6 @@
 #!/usr/bin/env python3
 
-# Copyright 2024 Google LLC
+# Copyright 2025 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -14,20 +14,26 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 #
-r"""Executable sample for enabling a rule.
+# pylint: disable=line-too-long
+r"""Executable and reusable v1alpha API sample for enabling a detection rule.
 
-Sample Commands (run from api_samples_python dir):
-    python3 detect.v1alpha.enable_rule -r=<region> \
-        -p=<project_id> -i=<instance_id> \
-        -rid=<rule_id>
+Usage:
+  python -m detect.v1alpha.enable_rule \
+    --project_id=<PROJECT_ID> \
+    --project_instance=<PROJECT_INSTANCE> \
+    --region=<REGION> \
+    --rule_id=ru_<UUID>
 
 API reference:
-    https://cloud.google.com/chronicle/docs/reference/rest/v1alpha/projects.locations.instances.rules/updateDeployment
-    https://cloud.google.com/chronicle/docs/reference/rest/v1alpha/RuleDeployment
+https://cloud.google.com/chronicle/docs/reference/rest/v1alpha/projects.locations.instances.rules/updateDeployment
+https://cloud.google.com/chronicle/docs/reference/rest/v1alpha/RuleDeployment
 """
+# pylint: enable=line-too-long
+
 import argparse
 import json
 from typing import Any, Mapping
+
 from common import chronicle_auth
 from common import project_id
 from common import project_instance
@@ -35,7 +41,6 @@
 from google.auth.transport import requests
 
 CHRONICLE_API_BASE_URL = "https://chronicle.googleapis.com"
-
 SCOPES = [
     "https://www.googleapis.com/auth/cloud-platform",
 ]
@@ -43,76 +48,67 @@
 
 def enable_rule(
     http_session: requests.AuthorizedSession,
-    proj_region: str,
     proj_id: str,
     proj_instance: str,
+    proj_region: str,
     rule_id: str,
 ) -> Mapping[str, Any]:
-  """Enables a rule.
+  """Enables a detection rule.
 
   Args:
     http_session: Authorized session for HTTP requests.
-    proj_region: region in which the target project is located
-    proj_id: GCP project id or number which the target instance belongs to
-    proj_instance: uuid of the instance whose rules are being
-      created (with dashes)
-    rule_id: Unique ID of the detection rule to retrieve ("ru_<UUID>").
+    proj_id: GCP project id or number to which the target instance belongs.
+    proj_instance: Customer ID (uuid with dashes) for the Chronicle instance.
+    proj_region: region in which the target project is located.
+    rule_id: Unique ID of the detection rule to enable
+      (in the format "ru_<UUID>").
 
   Returns:
-    a rule deployment object containing relevant rule's deployment information
+    Dictionary containing the rule's deployment information.
 
   Raises:
     requests.exceptions.HTTPError: HTTP request resulted in an error
       (response.status_code >= 400).
+
+  Requires the following IAM permission on the parent resource:
+  chronicle.rules.updateDeployment
   """
   base_url_with_region = regions.url_always_prepend_region(
-      CHRONICLE_API_BASE_URL,
-      args.region
-  )
+      CHRONICLE_API_BASE_URL, proj_region)
   # pylint: disable-next=line-too-long
   parent = f"projects/{proj_id}/locations/{proj_region}/instances/{proj_instance}"
   url = f"{base_url_with_region}/v1alpha/{parent}/rules/{rule_id}/deployment"
+
   body = {
-      # You can set enabled to False to disable a rule.
-      "enabled": True,
+      "enabled": True,  # Set to False to disable the rule
   }
   params = {"update_mask": "enabled"}
 
-  # See API reference links at top of this file, for response format.
   response = http_session.request("PATCH", url, params=params, json=body)
   if response.status_code >= 400:
     print(response.text)
   response.raise_for_status()
+
   return response.json()
 
 
 if __name__ == "__main__":
   parser = argparse.ArgumentParser()
+  # common
   chronicle_auth.add_argument_credentials_file(parser)
   project_instance.add_argument_project_instance(parser)
   project_id.add_argument_project_id(parser)
   regions.add_argument_region(parser)
-  parser.add_argument(
-      "-rid",
-      "--rule_id",
-      type=str,
-      required=True,
-      help='ID of rule to be enabled. In the form of "ru_<UUID>"',
-  )
+  # local
+  parser.add_argument("--rule_id",
+                      type=str,
+                      required=True,
+                      help='ID of rule to enable (format: "ru_<UUID>")')
+
   args = parser.parse_args()
-  auth_session = chronicle_auth.initialize_http_session(
-      args.credentials_file,
-      SCOPES
-  )
-  print(
-      json.dumps(
-          enable_rule(
-              auth_session,
-              args.region,
-              args.project_id,
-              args.project_instance,
-              args.rule_id,
-          ),
-          indent=2,
-      )
-  )
+
+  auth_session = chronicle_auth.initialize_http_session(args.credentials_file,
+                                                        SCOPES)
+  result = enable_rule(auth_session, args.project_id, args.project_instance,
+                       args.region, args.rule_id)
+  print(json.dumps(result, indent=2))
diff --git a/detect/v1alpha/get_alert.py b/detect/v1alpha/get_alert.py
index a56f768..6a66246 100644
--- a/detect/v1alpha/get_alert.py
+++ b/detect/v1alpha/get_alert.py
@@ -1,6 +1,6 @@
 #!/usr/bin/env python3
 
-# Copyright 2024 Google LLC
+# Copyright 2025 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -14,18 +14,20 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 #
-r"""Executable and reusable sample for getting a Reference List.
+# pylint: disable=line-too-long
+r"""Executable and reusable v1alpha API sample for getting an Alert.
 
 Usage:
-  python -m alerts.v1alpha.get_alert \
-    --project_id=<PROJECT_ID>   \
+  python -m detect.v1alpha.get_alert \
+    --project_id=<PROJECT_ID> \
     --project_instance=<PROJECT_INSTANCE> \
+    --region=<REGION> \
     --alert_id=<ALERT_ID>
 
 API reference:
-  https://cloud.google.com/chronicle/docs/reference/rest/v1alpha/projects.locations.instances.legacy/legacyGetAlert
-
+https://cloud.google.com/chronicle/docs/reference/rest/v1alpha/projects.locations.instances.legacy/legacyGetAlert
 """
+# pylint: enable=line-too-long
 
 import argparse
 import json
@@ -35,7 +37,6 @@
 from common import project_id
 from common import project_instance
 from common import regions
-
 from google.auth.transport import requests
 
 CHRONICLE_API_BASE_URL = "https://chronicle.googleapis.com"
@@ -52,7 +53,7 @@ def get_alert(
     alert_id: str,
     include_detections: bool = False,
 ) -> Mapping[str, Any]:
-  """Gets an Alert.
+  """Gets an Alert using the Legacy Get Alert API.
 
   Args:
     http_session: Authorized session for HTTP requests.
@@ -63,54 +64,54 @@ def get_alert(
     include_detections: Flag to include detections.
 
   Returns:
-    Dictionary representation of the Alert
+    Dictionary representation of the Alert.
 
   Raises:
     requests.exceptions.HTTPError: HTTP request resulted in an error
       (response.status_code >= 400).
+
+  Requires the following IAM permission on the parent resource:
+  chronicle.alerts.get
   """
   base_url_with_region = regions.url_always_prepend_region(
-      CHRONICLE_API_BASE_URL,
-      proj_region
-  )
-  # pylint: disable-next=line-too-long
+      CHRONICLE_API_BASE_URL, proj_region)
+  # pylint: disable=line-too-long
   parent = f"projects/{proj_id}/locations/{proj_region}/instances/{proj_instance}"
+  url = f"{base_url_with_region}/v1alpha/{parent}/legacy:legacyGetAlert"
+  # pylint: disable=line-too-long
 
   query_params = {"alertId": alert_id}
   if include_detections:
     query_params["includeDetections"] = True
 
-  url = f"{base_url_with_region}/v1alpha/{parent}/legacy:legacyGetAlert"
-
   response = http_session.request("GET", url, params=query_params)
-  # Expected server response is described in:
-  # https://cloud.google.com/chronicle/docs/reference/rest/v1alpha/projects.locations.instances.legacy/legacyGetAlert
   if response.status_code >= 400:
     print(response.text)
   response.raise_for_status()
+
   return response.json()
 
 
 if __name__ == "__main__":
   parser = argparse.ArgumentParser()
+  # common
   chronicle_auth.add_argument_credentials_file(parser)
   project_instance.add_argument_project_instance(parser)
   project_id.add_argument_project_id(parser)
   regions.add_argument_region(parser)
-  parser.add_argument(
-      "--alert_id", type=str, required=True,
-      help="identifier for the alert"
-  )
-  parser.add_argument(
-      "-d", "--include-detections", type=bool, default=False, required=False,
-      help="flag to include detections"
-  )
+  # local
+  parser.add_argument("--alert_id",
+                      type=str,
+                      required=True,
+                      help="Identifier for the alert")
+  parser.add_argument("--include-detections",
+                      action="store_true",
+                      help="Include detections in the response")
+
   args = parser.parse_args()
 
-  auth_session = chronicle_auth.initialize_http_session(
-      args.credentials_file,
-      SCOPES,
-  )
+  auth_session = chronicle_auth.initialize_http_session(args.credentials_file,
+                                                        SCOPES)
   alert = get_alert(
       auth_session,
       args.project_id,
diff --git a/detect/v1alpha/get_detection.py b/detect/v1alpha/get_detection.py
new file mode 100644
index 0000000..0abe681
--- /dev/null
+++ b/detect/v1alpha/get_detection.py
@@ -0,0 +1,123 @@
+#!/usr/bin/env python3
+
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# pylint: disable=line-too-long
+r"""Executable and reusable v1alpha API sample for getting a Detection.
+
+Usage:
+  python -m detect.v1alpha.get_detection \
+    --project_id=<PROJECT_ID> \
+    --project_instance=<PROJECT_INSTANCE> \
+    --region=<REGION> \
+    --detection_id=<DETECTION_ID> \
+    --rule_id=<RULE_ID>
+
+API reference:
+https://cloud.google.com/chronicle/docs/reference/rest/v1alpha/projects.locations.instances.legacy/legacyGetDetection
+"""
+# pylint: enable=line-too-long
+
+import argparse
+import json
+from typing import Any, Mapping
+
+from common import chronicle_auth
+from common import project_id
+from common import project_instance
+from common import regions
+from google.auth.transport import requests
+
+CHRONICLE_API_BASE_URL = "https://chronicle.googleapis.com"
+SCOPES = [
+    "https://www.googleapis.com/auth/cloud-platform",
+]
+
+
+def get_detection(
+    http_session: requests.AuthorizedSession,
+    proj_id: str,
+    proj_instance: str,
+    proj_region: str,
+    detection_id: str,
+    rule_id: str,
+) -> Mapping[str, Any]:
+  """Gets a Detection using the Legacy Get Detection API.
+
+  Args:
+    http_session: Authorized session for HTTP requests.
+    proj_id: GCP project id or number to which the target instance belongs.
+    proj_instance: Customer ID (uuid with dashes) for the Chronicle instance.
+    proj_region: region in which the target project is located.
+    detection_id: Identifier for the detection.
+    rule_id: Identifier for the rule that created the detection.
+
+  Returns:
+    Dictionary representation of the Detection.
+
+  Raises:
+    requests.exceptions.HTTPError: HTTP request resulted in an error
+      (response.status_code >= 400).
+
+  Requires the following IAM permission on the parent resource:
+  chronicle.detections.get
+  """
+  base_url_with_region = regions.url_always_prepend_region(
+      CHRONICLE_API_BASE_URL, proj_region)
+  # pylint: disable=line-too-long
+  parent = f"projects/{proj_id}/locations/{proj_region}/instances/{proj_instance}"
+  url = f"{base_url_with_region}/v1alpha/{parent}/legacy:legacyGetDetection"
+  # pylint: enable=line-too-long
+
+  query_params = {"detectionId": detection_id, "ruleId": rule_id}
+
+  response = http_session.request("GET", url, params=query_params)
+  if response.status_code >= 400:
+    print(response.text)
+  response.raise_for_status()
+
+  return response.json()
+
+
+if __name__ == "__main__":
+  parser = argparse.ArgumentParser()
+  # common
+  chronicle_auth.add_argument_credentials_file(parser)
+  project_instance.add_argument_project_instance(parser)
+  project_id.add_argument_project_id(parser)
+  regions.add_argument_region(parser)
+  # local
+  parser.add_argument("--detection_id",
+                      type=str,
+                      required=True,
+                      help="Identifier for the detection")
+  parser.add_argument("--rule_id",
+                      type=str,
+                      required=True,
+                      help="Identifier for the rule that created the detection")
+
+  args = parser.parse_args()
+
+  auth_session = chronicle_auth.initialize_http_session(args.credentials_file,
+                                                        SCOPES)
+  detection = get_detection(
+      auth_session,
+      args.project_id,
+      args.project_instance,
+      args.region,
+      args.detection_id,
+      args.rule_id,
+  )
+  print(json.dumps(detection, indent=2))
diff --git a/detect/v1alpha/get_retrohunt.py b/detect/v1alpha/get_retrohunt.py
index 8977b58..89596c6 100644
--- a/detect/v1alpha/get_retrohunt.py
+++ b/detect/v1alpha/get_retrohunt.py
@@ -1,6 +1,6 @@
 #!/usr/bin/env python3
 
-# Copyright 2024 Google LLC
+# Copyright 2025 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -14,24 +14,35 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 #
-r"""Executable sample for getting a retrohunt.
+# pylint: disable=line-too-long
+r"""Executable and reusable v1alpha API sample for getting a retrohunt.
 
-Sample Commands (run from api_samples_python dir):
-    python3 -m detect.v1alpha.get_retrohunt -r=<region> \
-        -p=<project_id> -i=<instance_id> \
-        -rid=<rule_id> -oid=<op_id>
+Usage:
+  python -m detect.v1alpha.get_retrohunt \
+    --project_id=<PROJECT_ID> \
+    --project_instance=<PROJECT_INSTANCE> \
+    --region=<REGION> \
+    --rule_id=ru_<UUID> \
+    --op_id=<OPERATION_ID>
 
-    python3 -m detect.v1alpha.get_retrohunt -r=<region> \
-        -p=<project_id> -i=<instance_id> \
-        -rid=<rule_id>@v_<seconds>_<nanoseconds> -oid=<op_id>
+  # You can also specify a specific rule version:
+  python -m detect.v1alpha.get_retrohunt \
+    --project_id=<PROJECT_ID> \
+    --project_instance=<PROJECT_INSTANCE> \
+    --region=<REGION> \
+    --rule_id=ru_<UUID>@v_<seconds>_<nanoseconds> \
+    --op_id=<OPERATION_ID>
 
 API reference:
-    https://cloud.google.com/chronicle/docs/reference/rest/v1alpha/projects.locations.instances.rules.retrohunts/get
-    https://cloud.google.com/chronicle/docs/reference/rest/v1alpha/projects.locations.instances.rules.retrohunts#Retrohunt
+https://cloud.google.com/chronicle/docs/reference/rest/v1alpha/projects.locations.instances.rules.retrohunts/get
+https://cloud.google.com/chronicle/docs/reference/rest/v1alpha/projects.locations.instances.rules.retrohunts#Retrohunt
 """
+# pylint: enable=line-too-long
+
 import argparse
 import json
 from typing import Any, Mapping
+
 from common import chronicle_auth
 from common import project_id
 from common import project_instance
@@ -39,7 +50,6 @@
 from google.auth.transport import requests
 
 CHRONICLE_API_BASE_URL = "https://chronicle.googleapis.com"
-
 SCOPES = [
     "https://www.googleapis.com/auth/cloud-platform",
 ]
@@ -47,85 +57,72 @@
 
 def get_retrohunt(
     http_session: requests.AuthorizedSession,
-    proj_region: str,
     proj_id: str,
     proj_instance: str,
+    proj_region: str,
     rule_id: str,
     op_id: str,
 ) -> Mapping[str, Any]:
-  """Get a retrohunt for a given rule.
+  """Gets information about a retrohunt for a specific detection rule.
 
   Args:
     http_session: Authorized session for HTTP requests.
-    proj_region: region in which the target project is located
-    proj_id: GCP project id or number which the target instance belongs to
-    proj_instance: uuid of the instance (with dashes)
-    rule_id: Unique ID of the detection rule to retrieve ("ru_<UUID>" or
-      "ru_<UUID>@v_<seconds>_<nanoseconds>"). If a version suffix isn't
-      specified we use the rule's latest version.
-    op_id: the operation ID of the retrohunt
+    proj_id: GCP project id or number to which the target instance belongs.
+    proj_instance: Customer ID (uuid with dashes) for the Chronicle instance.
+    proj_region: region in which the target project is located.
+    rule_id: Unique ID of the detection rule. Can be either "ru_<UUID>" or
+      "ru_<UUID>@v_<seconds>_<nanoseconds>". If version suffix isn't specified,
+      uses the rule's latest version.
+    op_id: Operation ID of the retrohunt to retrieve.
 
   Returns:
-    a retrohunt object containing relevant retrohunt's information
+    Dictionary containing information about the retrohunt.
 
   Raises:
     requests.exceptions.HTTPError: HTTP request resulted in an error
       (response.status_code >= 400).
+
+  Requires the following IAM permission on the parent resource:
+  chronicle.retrohunts.get
   """
   base_url_with_region = regions.url_always_prepend_region(
-      CHRONICLE_API_BASE_URL,
-      args.region
-  )
-  # pylint: disable-next=line-too-long
+      CHRONICLE_API_BASE_URL, proj_region)
+  # pylint: disable=line-too-long
   parent = f"projects/{proj_id}/locations/{proj_region}/instances/{proj_instance}"
   url = f"{base_url_with_region}/v1alpha/{parent}/rules/{rule_id}/retrohunts/{op_id}"
+  # pylint: enable=line-too-long
 
-  # See API reference links at top of this file, for response format.
   response = http_session.request("GET", url)
   if response.status_code >= 400:
     print(response.text)
   response.raise_for_status()
+
   return response.json()
 
 
 if __name__ == "__main__":
   parser = argparse.ArgumentParser()
+  # common
   chronicle_auth.add_argument_credentials_file(parser)
-  regions.add_argument_region(parser)
-  project_instance.add_argument_project_instance(parser)
   project_id.add_argument_project_id(parser)
+  project_instance.add_argument_project_instance(parser)
+  regions.add_argument_region(parser)
+  # local
   parser.add_argument(
-      "-rid",
       "--rule_id",
       type=str,
       required=True,
-      help=(
-          'rule ID to get retrohunt for. can use both "ru_<UUID>" or'
-          ' "ru_<UUID>@v_<seconds>_<nanoseconds>"'
-      ),
-  )
-  parser.add_argument(
-      "-oid",
-      "--op_id",
-      type=str,
-      required=True,
-      help="operation ID for the retrohunt",
-  )
+      help=('ID of rule to get retrohunt for. Format: "ru_<UUID>" or '
+            '"ru_<UUID>@v_<seconds>_<nanoseconds>"'))
+  parser.add_argument("--op_id",
+                      type=str,
+                      required=True,
+                      help="Operation ID of the retrohunt")
+
   args = parser.parse_args()
-  auth_session = chronicle_auth.initialize_http_session(
-      args.credentials_file,
-      SCOPES
-  )
-  print(
-      json.dumps(
-          get_retrohunt(
-              auth_session,
-              args.region,
-              args.project_id,
-              args.project_instance,
-              args.rule_id,
-              args.op_id,
-          ),
-          indent=2,
-      )
-  )
+
+  auth_session = chronicle_auth.initialize_http_session(args.credentials_file,
+                                                        SCOPES)
+  result = get_retrohunt(auth_session, args.project_id, args.project_instance,
+                         args.region, args.rule_id, args.op_id)
+  print(json.dumps(result, indent=2))
diff --git a/detect/v1alpha/get_rule.py b/detect/v1alpha/get_rule.py
index 661cf60..b40ed52 100644
--- a/detect/v1alpha/get_rule.py
+++ b/detect/v1alpha/get_rule.py
@@ -1,6 +1,6 @@
 #!/usr/bin/env python3
 
-# Copyright 2024 Google LLC
+# Copyright 2025 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -14,24 +14,19 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 #
-r"""Executable sample for getting a rule.
-
-Sample Commands (run from api_samples_python dir):
-  python3 -m detect.v1alpha.get_rule -r=<region> -p=<project_id> \
-      -i=<instance_id> -rid=<rule_id>
-
-  python3 -m detect.v1alpha.get_rule -r=<region> -p=<project_id> \
-      -i=<instance_id> -rid=<rule_id>@v_<seconds>_<nanoseconds>
+# pylint: disable=line-too-long
+r"""Executable and reusable v1alpha API sample for getting a rule.
 
 API reference:
-  https://cloud.google.com/chronicle/docs/reference/rest/v1alpha/projects.locations.instances.rules/get
-  https://cloud.google.com/chronicle/docs/reference/rest/v1alpha/projects.locations.instances.rules#Rule
+https://cloud.google.com/chronicle/docs/reference/rest/v1alpha/projects.locations.instances.rules/get
+https://cloud.google.com/chronicle/docs/reference/rest/v1alpha/projects.locations.instances.rules#Rule
 """
+# pylint: enable=line-too-long
 
 import argparse
 import json
-
 from typing import Any, Mapping
+
 from common import chronicle_auth
 from common import project_id
 from common import project_instance
@@ -39,7 +34,6 @@
 from google.auth.transport import requests
 
 CHRONICLE_API_BASE_URL = "https://chronicle.googleapis.com"
-
 SCOPES = [
     "https://www.googleapis.com/auth/cloud-platform",
 ]
@@ -47,74 +41,71 @@
 
 def get_rule(
     http_session: requests.AuthorizedSession,
-    proj_region: str,
     proj_id: str,
     proj_instance: str,
+    proj_region: str,
     rule_id: str,
 ) -> Mapping[str, Any]:
-  """Get a rule.
+  """Gets a rule using the Get Rule API.
 
   Args:
     http_session: Authorized session for HTTP requests.
-    proj_region: region in which the target project is located
-    proj_id: GCP project id or number which the target instance belongs to
-    proj_instance: uuid of the instance (with dashes)
+    proj_id: GCP project id or number to which the target instance belongs.
+    proj_instance: Customer ID (uuid w/ dashes) for the Chronicle instance.
+    proj_region: Region where the target project is located.
     rule_id: Unique ID of the detection rule to retrieve ("ru_<UUID>" or
-      "ru_<UUID>@v_<seconds>_<nanoseconds>"). If a version suffix isn't
-      specified we use the rule's latest version.
+        "ru_<UUID>@v_<seconds>_<nanoseconds>"). If a version suffix isn't
+        specified we use the rule's latest version.
 
   Returns:
-    a rule object containing relevant rule's information
+    Dictionary containing the rule's information.
+
   Raises:
     requests.exceptions.HTTPError: HTTP request resulted in an error
-      (response.status_code >= 400).
+        (response.status_code >= 400).
+
+  Requires the following IAM permission on the parent resource:
+  chronicle.rules.get
   """
   base_url_with_region = regions.url_always_prepend_region(
-      CHRONICLE_API_BASE_URL,
-      args.region
-  )
+      CHRONICLE_API_BASE_URL, proj_region)
   # pylint: disable-next=line-too-long
   parent = f"projects/{proj_id}/locations/{proj_region}/instances/{proj_instance}"
   url = f"{base_url_with_region}/v1alpha/{parent}/rules/{rule_id}"
 
-  # See API reference links at top of this file, for response format.
   response = http_session.request("GET", url)
   if response.status_code >= 400:
     print(response.text)
   response.raise_for_status()
+
   return response.json()
 
 
 if __name__ == "__main__":
   parser = argparse.ArgumentParser()
+  # common
   chronicle_auth.add_argument_credentials_file(parser)
   project_instance.add_argument_project_instance(parser)
   project_id.add_argument_project_id(parser)
   regions.add_argument_region(parser)
-  parser.add_argument(
-      "-rid",
-      "--rule_id",
-      type=str,
-      required=True,
-      help=(
-          'rule ID to get rule for. can use both "ru_<UUID>" or'
-          ' "ru_<UUID>@v_<seconds>_<nanoseconds>"'
-      ),
-  )
+  # local
+  parser.add_argument("--rule_id",
+                      type=str,
+                      required=True,
+                      help='Rule ID to retrieve ("ru_<UUID>" '
+                      'or "ru_<UUID>@v_<seconds>_<nanoseconds>")')
+
   args = parser.parse_args()
+
   auth_session = chronicle_auth.initialize_http_session(
       args.credentials_file,
-      SCOPES
+      SCOPES,
   )
-  print(
-      json.dumps(
-          get_rule(
-              auth_session,
-              args.region,
-              args.project_id,
-              args.project_instance,
-              args.rule_id
-          ),
-          indent=2,
-      )
+  rule = get_rule(
+      auth_session,
+      args.project_id,
+      args.project_instance,
+      args.region,
+      args.rule_id,
   )
+  print(json.dumps(rule, indent=2))
diff --git a/detect/v1alpha/list_detections.py b/detect/v1alpha/list_detections.py
index 00d5f95..dcb1fed 100644
--- a/detect/v1alpha/list_detections.py
+++ b/detect/v1alpha/list_detections.py
@@ -1,6 +1,6 @@
 #!/usr/bin/env python3
 
-# Copyright 2024 Google LLC
+# Copyright 2025 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -44,6 +44,7 @@
 import argparse
 import json
 from typing import Any, Mapping
+
 from common import chronicle_auth
 from common import project_id
 from common import project_instance
@@ -63,16 +64,14 @@
 )
 
 
-def list_detections(
-    http_session: requests.AuthorizedSession,
-    proj_region: str,
-    proj_id: str,
-    proj_instance: str,
-    rule_id: str,
-    alert_state: str | None = None,
-    page_size: int | None = None,
-    page_token: str | None = None
-) -> Mapping[str, Any]:
+def list_detections(http_session: requests.AuthorizedSession,
+                    proj_region: str,
+                    proj_id: str,
+                    proj_instance: str,
+                    rule_id: str,
+                    alert_state: str | None = None,
+                    page_size: int | None = None,
+                    page_token: str | None = None) -> Mapping[str, Any]:
   """List detections for a rule.
 
   Args:
@@ -95,9 +94,7 @@ def list_detections(
       (response.status_code >= 400).
   """
   base_url_with_region = regions.url_always_prepend_region(
-      CHRONICLE_API_BASE_URL,
-      args.region
-  )
+      CHRONICLE_API_BASE_URL, proj_region)
   # pylint: disable-next=line-too-long
   parent = f"projects/{proj_id}/locations/{proj_region}/instances/{proj_instance}"
   url = f"{base_url_with_region}/v1alpha/{parent}/legacy:legacySearchDetections"
@@ -130,11 +127,9 @@ def list_detections(
       "--rule_id",
       type=str,
       required=True,
-      help=(
-          "rule id to list detections for. Options are (1) rule_id (2)"
-          " rule_id@v_<seconds>_<nanoseconds> (3) rule_id@- which matches on"
-          " all versions."
-      ),
+      help=("rule id to list detections for. Options are (1) rule_id (2)"
+            " rule_id@v_<seconds>_<nanoseconds> (3) rule_id@- which matches on"
+            " all versions."),
   )
   parser.add_argument(
       "--alert_state",
@@ -155,10 +150,8 @@ def list_detections(
       default=None,
   )
   args = parser.parse_args()
-  auth_session = chronicle_auth.initialize_http_session(
-      args.credentials_file,
-      SCOPES
-  )
+  auth_session = chronicle_auth.initialize_http_session(args.credentials_file,
+                                                        SCOPES)
   print(
       json.dumps(
           list_detections(
@@ -172,5 +165,4 @@ def list_detections(
               args.page_token,
           ),
           indent=2,
-      )
-  )
+      ))
diff --git a/detect/v1alpha/list_errors.py b/detect/v1alpha/list_errors.py
index bb68b03..f22b812 100644
--- a/detect/v1alpha/list_errors.py
+++ b/detect/v1alpha/list_errors.py
@@ -1,6 +1,6 @@
 #!/usr/bin/env python3
 
-# Copyright 2024 Google LLC
+# Copyright 2025 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -36,6 +36,7 @@
 import argparse
 import json
 from typing import Any, Mapping
+
 from common import chronicle_auth
 from common import project_id
 from common import project_instance
@@ -74,9 +75,7 @@ def list_errors(
       (response.status_code >= 400).
   """
   base_url_with_region = regions.url_always_prepend_region(
-      CHRONICLE_API_BASE_URL,
-      args.region
-  )
+      CHRONICLE_API_BASE_URL, args.region)
   # pylint: disable-next=line-too-long
   parent = f"projects/{proj_id}/locations/{proj_region}/instances/{proj_instance}"
   url = f"{base_url_with_region}/v1alpha/{parent}/ruleExecutionErrors"
@@ -107,17 +106,13 @@ def list_errors(
       "--rule_id",
       type=str,
       required=True,
-      help=(
-          "rule id to list errors for. Options are (1) rule_id (2)"
-          " rule_id@v_<seconds>_<nanoseconds> (3) rule_id@- which matches on"
-          " all versions."
-      ),
+      help=("rule id to list errors for. Options are (1) rule_id (2)"
+            " rule_id@v_<seconds>_<nanoseconds> (3) rule_id@- which matches on"
+            " all versions."),
   )
   args = parser.parse_args()
-  auth_session = chronicle_auth.initialize_http_session(
-      args.credentials_file,
-      SCOPES
-  )
+  auth_session = chronicle_auth.initialize_http_session(args.credentials_file,
+                                                        SCOPES)
   print(
       json.dumps(
           list_errors(
@@ -128,5 +123,4 @@ def list_errors(
               args.rule_id,
           ),
           indent=2,
-      )
-  )
+      ))
diff --git a/detect/v1alpha/list_rules.py b/detect/v1alpha/list_rules.py
index d870104..2c94bc1 100644
--- a/detect/v1alpha/list_rules.py
+++ b/detect/v1alpha/list_rules.py
@@ -1,6 +1,6 @@
 #!/usr/bin/env python3
 
-# Copyright 2024 Google LLC
+# Copyright 2025 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -14,16 +14,20 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 #
-r"""Executable and reusable sample for retrieving a list of rules.
+# pylint: disable=line-too-long
+r"""Executable and reusable v1alpha API sample for retrieving a list of rules.
 
-Sample Commands (run from api_samples_python dir):
-  python3 -m detect.v1alpha.list_rules -r=<region> \
-      -p=<project_id> -i=<instance_id>
+Usage:
+  python -m detect.v1alpha.list_rules \
+    --project_id=<PROJECT_ID> \
+    --project_instance=<PROJECT_INSTANCE> \
+    --region=<REGION>
 
 API reference:
-  https://cloud.google.com/chronicle/docs/reference/rest/v1alpha/projects.locations.instances.rules/list
-  https://cloud.google.com/chronicle/docs/reference/rest/v1alpha/projects.locations.instances.rules#Rule
+https://cloud.google.com/chronicle/docs/reference/rest/v1alpha/projects.locations.instances.rules/list
+https://cloud.google.com/chronicle/docs/reference/rest/v1alpha/projects.locations.instances.rules#Rule
 """
+# pylint: enable=line-too-long
 
 import argparse
 import json
@@ -36,7 +40,6 @@
 from google.auth.transport import requests
 
 CHRONICLE_API_BASE_URL = "https://chronicle.googleapis.com"
-
 SCOPES = [
     "https://www.googleapis.com/auth/cloud-platform",
 ]
@@ -47,7 +50,7 @@ def list_rules(
     proj_id: str,
     proj_instance: str,
     proj_region: str,
-    ) -> Mapping[str, Any]:
+) -> Mapping[str, Any]:
   """Gets a list of rules.
 
   Args:
@@ -55,43 +58,43 @@ def list_rules(
     proj_id: GCP project id or number to which the target instance belongs.
     proj_instance: Customer ID (uuid with dashes) for the Chronicle instance.
     proj_region: region in which the target project is located.
+
   Returns:
-    Array containing information about rules.
+    Dictionary containing an array of rules.
+
   Raises:
     requests.exceptions.HTTPError: HTTP request resulted in an error
       (response.status_code >= 400).
+
+  Requires the following IAM permission on the parent resource:
+  chronicle.rules.list
   """
   base_url_with_region = regions.url_always_prepend_region(
-      CHRONICLE_API_BASE_URL,
-      args.region
-  )
+      CHRONICLE_API_BASE_URL, proj_region)
   # pylint: disable-next=line-too-long
   parent = f"projects/{proj_id}/locations/{proj_region}/instances/{proj_instance}"
   url = f"{base_url_with_region}/v1alpha/{parent}/rules"
 
-  # See API reference links at top of this file, for response format.
   response = http_session.request("GET", url)
   if response.status_code >= 400:
     print(response.text)
   response.raise_for_status()
+
   return response.json()
 
 
 if __name__ == "__main__":
   parser = argparse.ArgumentParser()
+  # common
   chronicle_auth.add_argument_credentials_file(parser)
   project_instance.add_argument_project_instance(parser)
   project_id.add_argument_project_id(parser)
   regions.add_argument_region(parser)
+
   args = parser.parse_args()
-  session = chronicle_auth.initialize_http_session(
-      args.credentials_file,
-      SCOPES
-  )
-  rules = list_rules(
-      session,
-      args.project_id,
-      args.project_instance,
-      args.region
-  )
+
+  auth_session = chronicle_auth.initialize_http_session(args.credentials_file,
+                                                        SCOPES)
+  rules = list_rules(auth_session, args.project_id, args.project_instance,
+                     args.region)
   print(json.dumps(rules, indent=2))
diff --git a/detect/v1alpha/update_alert.py b/detect/v1alpha/update_alert.py
index cf3aaf6..ea17908 100644
--- a/detect/v1alpha/update_alert.py
+++ b/detect/v1alpha/update_alert.py
@@ -1,6 +1,6 @@
 #!/usr/bin/env python3
 
-# Copyright 2024 Google LLC
+# Copyright 2025 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -55,7 +55,6 @@
 from common import project_id
 from common import project_instance
 from common import regions
-
 from google.auth.transport import requests
 
 CHRONICLE_API_BASE_URL = "https://chronicle.googleapis.com"
@@ -183,29 +182,26 @@ def get_update_parser():
   return parser
 
 
-def check_args(
-    parser: argparse.ArgumentParser,
-    args_to_check: argparse.Namespace):
+def check_args(parser: argparse.ArgumentParser,
+               args_to_check: argparse.Namespace):
   """Checks if at least one of the required arguments is provided.
 
   Args:
     parser: instance of argparse.ArgumentParser (to raise error if needed).
     args_to_check: instance of argparse.Namespace with the arguments to check.
   """
-  if not any(
-      [
-          args_to_check.comment or args_to_check.comment == "",  # pylint: disable=g-explicit-bool-comparison
-          args_to_check.disregarded,
-          args_to_check.priority,
-          args_to_check.reason,
-          args_to_check.reputation,
-          args_to_check.risk_score or args_to_check.risk_score == 0,
-          args_to_check.root_cause or args_to_check.root_cause == "",  # pylint: disable=g-explicit-bool-comparison
-          args_to_check.severity or args_to_check.severity == 0,
-          args_to_check.status,
-          args_to_check.verdict,
-      ]
-  ):
+  if not any([
+      args_to_check.comment or args_to_check.comment == "",  # pylint: disable=g-explicit-bool-comparison
+      args_to_check.disregarded,
+      args_to_check.priority,
+      args_to_check.reason,
+      args_to_check.reputation,
+      args_to_check.risk_score or args_to_check.risk_score == 0,
+      args_to_check.root_cause or args_to_check.root_cause == "",  # pylint: disable=g-explicit-bool-comparison
+      args_to_check.severity or args_to_check.severity == 0,
+      args_to_check.status,
+      args_to_check.verdict,
+  ]):
     parser.error("At least one of the arguments "
                  "--comment, "
                  "--disregarded, "
@@ -237,7 +233,7 @@ def update_alert(
     severity: int | None = None,
     comment: str | Literal[""] | None = None,
     root_cause: str | Literal[""] | None = None,
-    ) -> Mapping[str, Any]:
+) -> Mapping[str, Any]:
   """Updates an Alert.
 
   Args:
@@ -266,9 +262,7 @@ def update_alert(
       (response.status_code >= 400).
   """
   base_url_with_region = regions.url_always_prepend_region(
-      CHRONICLE_API_BASE_URL,
-      proj_region
-  )
+      CHRONICLE_API_BASE_URL, proj_region)
   # pylint: disable-next=line-too-long
   parent = f"projects/{proj_id}/locations/{proj_region}/instances/{proj_instance}"
   url = f"{base_url_with_region}/v1alpha/{parent}/legacy:legacyUpdateAlert/"
@@ -314,10 +308,10 @@ def update_alert(
 
 if __name__ == "__main__":
   main_parser = get_update_parser()
-  main_parser.add_argument(
-      "--alert_id", type=str, required=True,
-      help="identifier for the alert"
-  )
+  main_parser.add_argument("--alert_id",
+                           type=str,
+                           required=True,
+                           help="identifier for the alert")
   args = main_parser.parse_args()
 
   # Check if at least one of the specific arguments is provided
diff --git a/detect/v1alpha/update_rule.py b/detect/v1alpha/update_rule.py
index 745dc60..6d189be 100644
--- a/detect/v1alpha/update_rule.py
+++ b/detect/v1alpha/update_rule.py
@@ -1,6 +1,6 @@
 #!/usr/bin/env python3
 
-# Copyright 2024 Google LLC
+# Copyright 2025 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -14,6 +14,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 #
+# pylint: disable=line-too-long
 r"""Executable sample for updating a rule.
 
 Sample Commands (run from api_samples_python dir):
@@ -32,9 +33,11 @@
     https://cloud.google.com/chronicle/docs/reference/rest/v1alpha/projects.locations.instances.rules/patch
     https://cloud.google.com/chronicle/docs/reference/rest/v1alpha/projects.locations.instances.rules#Rule
 """
+# pylint: enable=line-too-long
 import argparse
 import json
 from typing import Any, Mapping
+
 from common import chronicle_auth
 from common import project_id
 from common import project_instance
@@ -74,9 +77,7 @@ def update_rule(
 
   """
   base_url_with_region = regions.url_always_prepend_region(
-      CHRONICLE_API_BASE_URL,
-      args.region
-  )
+      CHRONICLE_API_BASE_URL, args.region)
   # pylint: disable-next=line-too-long
   parent = f"projects/{proj_id}/locations/{proj_region}/instances/{proj_instance}"
   url = f"{base_url_with_region}/v1alpha/{parent}/rules/{rule_id}"
@@ -115,10 +116,8 @@ def update_rule(
       help="path of a file with the desired rule's content, or - for STDIN",
   )
   args = parser.parse_args()
-  auth_session = chronicle_auth.initialize_http_session(
-      args.credentials_file,
-      SCOPES
-  )
+  auth_session = chronicle_auth.initialize_http_session(args.credentials_file,
+                                                        SCOPES)
   print(
       json.dumps(
           update_rule(
@@ -130,5 +129,4 @@ def update_rule(
               args.rule_file,
           ),
           indent=2,
-      )
-  )
+      ))
diff --git a/ingestion/v1alpha/__init__.py b/ingestion/v1alpha/__init__.py
new file mode 100644
index 0000000..3d87ee0
--- /dev/null
+++ b/ingestion/v1alpha/__init__.py
@@ -0,0 +1,14 @@
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
diff --git a/ingestion/v1alpha/create_udm_events.py b/ingestion/v1alpha/create_udm_events.py
index 04925b2..fefd5f6 100644
--- a/ingestion/v1alpha/create_udm_events.py
+++ b/ingestion/v1alpha/create_udm_events.py
@@ -1,6 +1,6 @@
 #!/usr/bin/env python3
 
-# Copyright 2024 Google LLC
+# Copyright 2025 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -32,12 +32,11 @@
 import argparse
 import json
 
-from google.auth.transport import requests
-
 from common import chronicle_auth
 from common import project_id
 from common import project_instance
 from common import regions
+from google.auth.transport import requests
 
 CHRONICLE_API_BASE_URL = "https://chronicle.googleapis.com"
 SCOPES = [
@@ -45,9 +44,8 @@
 ]
 
 
-def create_udm_events(
-    http_session: requests.AuthorizedSession, json_events: str
-) -> None:
+def create_udm_events(http_session: requests.AuthorizedSession,
+                      json_events: str) -> None:
   """Sends a collection of UDM events to the Google SecOps backend for ingestion.
 
   A Unified Data Model (UDM) event is a structured representation of an event
@@ -70,13 +68,17 @@ def create_udm_events(
   """
 
   base_url_with_region = regions.url_always_prepend_region(
-      CHRONICLE_API_BASE_URL,
-      args.region
-  )
+      CHRONICLE_API_BASE_URL, args.region)
   # pylint: disable-next=line-too-long
   parent = f"projects/{args.project_id}/locations/{args.region}/instances/{args.project_instance}"
   url = f"{base_url_with_region}/v1alpha/{parent}/events:import"
-  body = {"inline_source": {"events": [{"udm": json.loads(json_events)[0],}]}}
+  body = {
+      "inline_source": {
+          "events": [{
+              "udm": json.loads(json_events)[0],
+          }]
+      }
+  }
 
   response = http_session.request("POST", url, json=body)
   print(response)
@@ -98,9 +100,7 @@ def create_udm_events(
       "--json_events_file",
       type=argparse.FileType("r"),
       required=True,
-      help=(
-          "path to a file containing a list of UDM events in json format"
-      ),
+      help=("path to a file containing a list of UDM events in json format"),
   )
   args = parser.parse_args()
 
diff --git a/ingestion/v1alpha/event_import.py b/ingestion/v1alpha/event_import.py
new file mode 100644
index 0000000..9a0b4d0
--- /dev/null
+++ b/ingestion/v1alpha/event_import.py
@@ -0,0 +1,104 @@
+#!/usr/bin/env python3
+
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# pylint: disable=line-too-long
+r"""Executable and reusable v1alpha API sample for importing events into Chronicle.
+
+API reference:
+https://cloud.google.com/chronicle/docs/reference/rest/v1alpha/projects.locations.instances.events/import
+"""
+# pylint: enable=line-too-long
+
+import argparse
+import json
+
+from common import chronicle_auth
+from common import project_id
+from common import project_instance
+from common import regions
+from google.auth.transport import requests
+
+CHRONICLE_API_BASE_URL = "https://chronicle.googleapis.com"
+SCOPES = [
+    "https://www.googleapis.com/auth/cloud-platform",
+]
+
+
+def import_events(http_session: requests.AuthorizedSession, proj_id: str,
+                  proj_instance: str, proj_region: str,
+                  json_events: str) -> None:
+  """Import events into Chronicle using the Events Import API.
+
+  Args:
+      http_session: Authorized session for HTTP requests.
+      proj_id: GCP project id or number to which the target instance belongs.
+      proj_instance: Customer ID (uuid w/ dashes) for the Chronicle instance.
+      proj_region: region in which the target project is located.
+      json_events: Events in (serialized) JSON format.
+
+  Raises:
+      requests.exceptions.HTTPError: HTTP request resulted in an error
+          (response.status_code >= 400).
+
+  Requires the following IAM permission on the parent resource:
+  chronicle.events.import
+  """
+  base_url_with_region = regions.url_always_prepend_region(
+      CHRONICLE_API_BASE_URL, proj_region)
+  # pylint: disable=line-too-long
+  parent = f"projects/{proj_id}/locations/{proj_region}/instances/{proj_instance}"
+  url = f"{base_url_with_region}/v1alpha/{parent}/events:import"
+  # pylint: enable=line-too-long
+
+  body = {
+      "events": json.loads(json_events),
+  }
+
+  response = http_session.request("POST", url, json=body)
+  if response.status_code >= 400:
+    print(response.text)
+  response.raise_for_status()
+
+  result = response.json()
+  if "successCount" in result:
+    print(f"Successfully imported {result['successCount']} events")
+  if "failureCount" in result:
+    print(f"Failed to import {result['failureCount']} events")
+
+
+if __name__ == "__main__":
+  parser = argparse.ArgumentParser()
+  # common
+  chronicle_auth.add_argument_credentials_file(parser)
+  project_instance.add_argument_project_instance(parser)
+  project_id.add_argument_project_id(parser)
+  regions.add_argument_region(parser)
+  # local
+  parser.add_argument(
+      "--json_events_file",
+      type=argparse.FileType("r"),
+      required=True,
+      help="path to a file (or \"-\" for STDIN) containing events in JSON "
+      "format"
+  )
+
+  args = parser.parse_args()
+  auth_session = chronicle_auth.initialize_http_session(
+      args.credentials_file,
+      SCOPES,
+  )
+  import_events(auth_session, args.project_id, args.project_instance,
+                args.region, args.json_events_file.read())
diff --git a/ingestion/v1alpha/events_batch_get.py b/ingestion/v1alpha/events_batch_get.py
new file mode 100644
index 0000000..156ac87
--- /dev/null
+++ b/ingestion/v1alpha/events_batch_get.py
@@ -0,0 +1,108 @@
+#!/usr/bin/env python3
+
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# pylint: disable=line-too-long
+r"""Executable and reusable v1alpha API sample for batch getting events from Chronicle.
+
+API reference:
+https://cloud.google.com/chronicle/docs/reference/rest/v1alpha/projects.locations.instances.events/batchGet
+"""
+# pylint: enable=line-too-long
+
+import argparse
+import base64
+import json
+
+from common import chronicle_auth
+from common import project_id
+from common import project_instance
+from common import regions
+from google.auth.transport import requests
+
+CHRONICLE_API_BASE_URL = "https://chronicle.googleapis.com"
+SCOPES = [
+    "https://www.googleapis.com/auth/cloud-platform",
+]
+
+
+def batch_get_events(
+    http_session: requests.AuthorizedSession,
+    proj_id: str,
+    proj_instance: str,
+    proj_region: str,
+    event_ids: str
+) -> None:
+  """Batch get events from Chronicle using the Events BatchGet API.
+
+  Args:
+    http_session: Authorized session for HTTP requests.
+    proj_id: GCP project id or number to which the target instance belongs.
+    proj_instance: Customer ID (uuid w/ dashes) for the Chronicle instance.
+    proj_region: region in which the target project is located.
+    event_ids: JSON string containing a list of event IDs to retrieve.
+
+  Raises:
+    requests.exceptions.HTTPError: HTTP request resulted in an error
+        (response.status_code >= 400).
+
+  Requires the following IAM permission on the parent resource:
+  chronicle.events.batchGet
+  """
+  base_url_with_region = regions.url_always_prepend_region(
+      CHRONICLE_API_BASE_URL, proj_region)
+  # pylint: disable=line-too-long
+  parent = f"projects/{proj_id}/locations/{proj_region}/instances/{proj_instance}"
+  url = f"{base_url_with_region}/v1alpha/{parent}/events:batchGet"
+  # pylint: enable=line-too-long
+
+  # Convert event IDs to URL-encoded Base64 and create query parameters
+  event_ids_list = json.loads(event_ids)
+  encoded_ids = [
+      base64.urlsafe_b64encode(id.encode()).decode() for id in event_ids_list
+  ]
+  query_params = "&".join([f"names={id}" for id in encoded_ids])
+
+  url = f"{url}?{query_params}"
+
+  response = http_session.request("GET", url)
+  if response.status_code >= 400:
+    print(response.text)
+  response.raise_for_status()
+
+  print(json.dumps(response.json(), indent=2))
+
+
+if __name__ == "__main__":
+  parser = argparse.ArgumentParser()
+  # common
+  chronicle_auth.add_argument_credentials_file(parser)
+  project_instance.add_argument_project_instance(parser)
+  project_id.add_argument_project_id(parser)
+  regions.add_argument_region(parser)
+  # local
+  parser.add_argument("--event_ids",
+                      type=str,
+                      required=True,
+                      help='JSON string containing a list of event IDs to '
+                      'retrieve (e.g., \'["id1", "id2"]\')')
+
+  args = parser.parse_args()
+  auth_session = chronicle_auth.initialize_http_session(
+      args.credentials_file,
+      SCOPES,
+  )
+  batch_get_events(auth_session, args.project_id, args.project_instance,
+                   args.region, args.event_ids)
diff --git a/ingestion/v1alpha/events_get.py b/ingestion/v1alpha/events_get.py
new file mode 100644
index 0000000..af3e7ab
--- /dev/null
+++ b/ingestion/v1alpha/events_get.py
@@ -0,0 +1,92 @@
+#!/usr/bin/env python3
+
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# pylint: disable=line-too-long
+r"""Executable and reusable v1alpha API sample for getting event details from Chronicle.
+
+API reference:
+https://cloud.google.com/chronicle/docs/reference/rest/v1alpha/projects.locations.instances.events/get
+"""
+# pylint: enable=line-too-long
+
+import argparse
+import base64
+
+from common import chronicle_auth
+from common import project_id
+from common import project_instance
+from common import regions
+from google.auth.transport import requests
+
+CHRONICLE_API_BASE_URL = "https://chronicle.googleapis.com"
+SCOPES = [
+    "https://www.googleapis.com/auth/cloud-platform",
+]
+
+
+def get_event(http_session: requests.AuthorizedSession, proj_id: str,
+              proj_instance: str, proj_region: str, event_id: str) -> None:
+  """Get event details from Chronicle using the Events Get API.
+
+  Args:
+    http_session: Authorized session for HTTP requests.
+    proj_id: GCP project id or number to which the target instance belongs.
+    proj_instance: Customer ID (uuid with dashes) for the Chronicle instance.
+    proj_region: region in which the target project is located.
+    event_id: The ID of the event to retrieve.
+
+  Raises:
+    requests.exceptions.HTTPError: HTTP request resulted in an error
+        (response.status_code >= 400).
+
+  Requires the following IAM permission on the parent resource:
+  chronicle.events.get
+  """
+  # URL encode the event_id in Base64
+  encoded_event_id = base64.urlsafe_b64encode(event_id.encode()).decode()
+  base_url_with_region = regions.url_always_prepend_region(
+      CHRONICLE_API_BASE_URL, proj_region)
+  name = f"projects/{proj_id}/locations/{proj_region}/instances/{proj_instance}/events/{encoded_event_id}"
+  url = f"{base_url_with_region}/v1alpha/{name}"
+
+  response = http_session.request("GET", url)
+  if response.status_code >= 400:
+    print(response.text)
+  response.raise_for_status()
+
+  print(response.json())
+
+
+if __name__ == "__main__":
+  parser = argparse.ArgumentParser()
+  # common
+  chronicle_auth.add_argument_credentials_file(parser)
+  project_instance.add_argument_project_instance(parser)
+  project_id.add_argument_project_id(parser)
+  regions.add_argument_region(parser)
+  # local
+  parser.add_argument("--event_id",
+                      type=str,
+                      required=True,
+                      help="The ID of the event to retrieve")
+
+  args = parser.parse_args()
+  auth_session = chronicle_auth.initialize_http_session(
+      args.credentials_file,
+      SCOPES,
+  )
+  get_event(auth_session, args.project_id, args.project_instance, args.region,
+            args.event_id)
diff --git a/iocs/__init__.py b/iocs/__init__.py
new file mode 100644
index 0000000..3d87ee0
--- /dev/null
+++ b/iocs/__init__.py
@@ -0,0 +1,14 @@
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
diff --git a/iocs/v1alpha/__init__.py b/iocs/v1alpha/__init__.py
new file mode 100644
index 0000000..3d87ee0
--- /dev/null
+++ b/iocs/v1alpha/__init__.py
@@ -0,0 +1,14 @@
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
diff --git a/iocs/v1alpha/batch_get_iocs.py b/iocs/v1alpha/batch_get_iocs.py
new file mode 100644
index 0000000..55f72fc
--- /dev/null
+++ b/iocs/v1alpha/batch_get_iocs.py
@@ -0,0 +1,81 @@
+#!/usr/bin/env python3
+
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Get multiple IoCs from Chronicle."""
+
+from typing import List, Mapping, Any
+
+from common import regions
+from google.auth.transport import requests
+
+CHRONICLE_API_BASE_URL = "https://chronicle.googleapis.com"
+
+
+def batch_get_iocs(
+    http_session: requests.AuthorizedSession,
+    proj_id: str,
+    proj_instance: str,
+    proj_region: str,
+    ioc_values: List[str],
+    ioc_type: str,
+) -> Mapping[str, Any]:
+  """Get multiple IoCs by their values from Chronicle.
+
+  Args:
+    http_session: Authorized session for HTTP requests.
+    proj_id: GCP project id or number to which the target instance belongs.
+    proj_instance: Customer ID (uuid with dashes) for the instance.
+    proj_region: region in which the target project is located.
+    ioc_values: List of IoC values to retrieve.
+    ioc_type: Type of IoCs being requested. One of:
+      IOC_TYPE_UNSPECIFIED
+      DOMAIN
+      IP
+      FILE_HASH
+      URL
+      USER_EMAIL
+      MUTEX
+      FILE_HASH_MD5
+      FILE_HASH_SHA1
+      FILE_HASH_SHA256
+      IOC_TYPE_RESOURCE
+
+  Returns:
+    Dict containing the requested IoCs.
+
+  Raises:
+    requests.exceptions.HTTPError: HTTP request resulted in an error
+        (response.status_code >= 400).
+  """
+  base_url_with_region = regions.url_always_prepend_region(
+      CHRONICLE_API_BASE_URL, proj_region)
+  # pylint: disable-next=line-too-long
+  instance = f"projects/{proj_id}/locations/{proj_region}/instances/{proj_instance}"
+  url = f"{base_url_with_region}/v1alpha/{instance}/iocs:batchGet"
+
+  body = {
+      "ioc_values": ioc_values,
+      "ioc_type": ioc_type,
+  }
+
+  response = http_session.request(
+      "POST",
+      url,
+      json=body,
+  )
+  if response.status_code >= 400:
+    print(response.text)
+  response.raise_for_status()
+  return response.json()
diff --git a/iocs/v1alpha/get_ioc.py b/iocs/v1alpha/get_ioc.py
new file mode 100644
index 0000000..1fd0a44
--- /dev/null
+++ b/iocs/v1alpha/get_ioc.py
@@ -0,0 +1,73 @@
+#!/usr/bin/env python3
+
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Get a single IoC from Chronicle."""
+
+from typing import Any, Mapping
+
+from common import regions
+from google.auth.transport import requests
+
+CHRONICLE_API_BASE_URL = "https://chronicle.googleapis.com"
+
+
+def get_ioc(
+    http_session: requests.AuthorizedSession,
+    proj_id: str,
+    proj_instance: str,
+    proj_region: str,
+    ioc_value: str,
+    ioc_type: str,
+) -> Mapping[str, Any]:
+  """Get a single IoC by its value from Chronicle.
+
+  Args:
+      http_session: Authorized session for HTTP requests.
+      proj_id: GCP project id or number to which the target instance belongs.
+      proj_instance: Customer ID (uuid with dashes) for the instance.
+      proj_region: region in which the target project is located.
+      ioc_value: Value of the IoC to retrieve.
+      ioc_type: Type of IoC being requested. One of:
+          IOC_TYPE_UNSPECIFIED
+          DOMAIN
+          IP
+          FILE_HASH
+          URL
+          USER_EMAIL
+          MUTEX
+          FILE_HASH_MD5
+          FILE_HASH_SHA1
+          FILE_HASH_SHA256
+          IOC_TYPE_RESOURCE
+
+  Returns:
+      Mapping containing the requested IoC.
+
+  Raises:
+      requests.exceptions.HTTPError: HTTP request resulted in an error
+          (response.status_code >= 400).
+  """
+  base_url_with_region = regions.url_always_prepend_region(
+      CHRONICLE_API_BASE_URL, proj_region)
+  # pylint: disable=line-too-long
+  instance = f"projects/{proj_id}/locations/{proj_region}/instances/{proj_instance}"
+  url = f"{base_url_with_region}/v1alpha/{instance}/iocs/{ioc_type}/{ioc_value}"
+  # pylint: enable=line-too-long
+
+  response = http_session.request("GET", url)
+  if response.status_code >= 400:
+    print(response.text)
+  response.raise_for_status()
+  return response.json()
diff --git a/iocs/v1alpha/get_ioc_state.py b/iocs/v1alpha/get_ioc_state.py
new file mode 100644
index 0000000..911ad85
--- /dev/null
+++ b/iocs/v1alpha/get_ioc_state.py
@@ -0,0 +1,72 @@
+#!/usr/bin/env python3
+
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Get the state of an IoC from Chronicle."""
+
+from typing import Any, Mapping
+
+from common import regions
+from google.auth.transport import requests
+
+CHRONICLE_API_BASE_URL = "https://chronicle.googleapis.com"
+
+
+def get_ioc_state(
+    http_session: requests.AuthorizedSession,
+    proj_id: str,
+    proj_instance: str,
+    proj_region: str,
+    ioc_value: str,
+    ioc_type: str,
+) -> Mapping[str, Any]:
+  """Get the state of an IoC by its value from Chronicle.
+
+  Args:
+    http_session: Authorized session for HTTP requests.
+    proj_id: GCP project id or number to which the target instance belongs.
+    proj_instance: Customer ID (uuid with dashes) for the instance.
+    proj_region: region in which the target project is located.
+    ioc_value: Value of the IoC to get state for.
+    ioc_type: Type of IoC being requested. One of:
+        IOC_TYPE_UNSPECIFIED
+        DOMAIN
+        IP
+        FILE_HASH
+        URL
+        USER_EMAIL
+        MUTEX
+        FILE_HASH_MD5
+        FILE_HASH_SHA1
+        FILE_HASH_SHA256
+        IOC_TYPE_RESOURCE
+
+  Returns:
+    Dict containing the IoC state.
+
+  Raises:
+      requests.exceptions.HTTPError: HTTP request resulted in an error
+          (response.status_code >= 400).
+  """
+  base_url_with_region = regions.url_always_prepend_region(
+      CHRONICLE_API_BASE_URL, proj_region)
+  # pylint: disable=line-too-long
+  instance = f"projects/{proj_id}/locations/{proj_region}/instances/{proj_instance}"
+  url = f"{base_url_with_region}/v1alpha/{instance}/iocs/{ioc_type}/{ioc_value}:getIocState"
+  # pylint: enable=line-too-long
+  response = http_session.request("GET", url)
+  if response.status_code >= 400:
+    print(response.text)
+  response.raise_for_status()
+  return response.json()
diff --git a/lists/append_to_list.py b/lists/append_to_list.py
index cd3c5a0..38b2101 100644
--- a/lists/append_to_list.py
+++ b/lists/append_to_list.py
@@ -1,6 +1,6 @@
 #!/usr/bin/env python3
 
-# Copyright 2024 Google LLC
+# Copyright 2025 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
diff --git a/lists/remove_from_list.py b/lists/remove_from_list.py
index bde347c..e31d5ab 100644
--- a/lists/remove_from_list.py
+++ b/lists/remove_from_list.py
@@ -1,6 +1,6 @@
 #!/usr/bin/env python3
 
-# Copyright 2024 Google LLC
+# Copyright 2025 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
diff --git a/lists/v1alpha/__init__.py b/lists/v1alpha/__init__.py
new file mode 100644
index 0000000..3d87ee0
--- /dev/null
+++ b/lists/v1alpha/__init__.py
@@ -0,0 +1,14 @@
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
diff --git a/lists/v1alpha/get_list.py b/lists/v1alpha/get_list.py
index f329631..97c142e 100644
--- a/lists/v1alpha/get_list.py
+++ b/lists/v1alpha/get_list.py
@@ -1,6 +1,6 @@
 #!/usr/bin/env python3
 
-# Copyright 2024 Google LLC
+# Copyright 2025 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -36,7 +36,6 @@
 from common import project_id
 from common import project_instance
 from common import regions
-
 from google.auth.transport import requests
 
 CHRONICLE_API_BASE_URL = "https://chronicle.googleapis.com"
diff --git a/lists/v1alpha/patch_list.py b/lists/v1alpha/patch_list.py
index 2092ed0..e2f98d1 100644
--- a/lists/v1alpha/patch_list.py
+++ b/lists/v1alpha/patch_list.py
@@ -1,6 +1,6 @@
 #!/usr/bin/env python3
 
-# Copyright 2024 Google LLC
+# Copyright 2025 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -117,15 +117,15 @@ def patch_list(
       (response.status_code >= 400).
   """
   base_url_with_region = regions.url_always_prepend_region(
-      CHRONICLE_API_BASE_URL,
-      proj_region
-  )
+      CHRONICLE_API_BASE_URL, proj_region)
 
   # pylint: disable-next=line-too-long
   parent = f"projects/{proj_id}/locations/{proj_region}/instances/{proj_instance}"
   url = f"{base_url_with_region}/v1alpha/{parent}/referenceLists/{name}"
   body = {
-      "entries": [{"value": line.strip()} for line in content_lines],
+      "entries": [{
+          "value": line.strip()
+      } for line in content_lines],
   }
   if scope_name:
     body["scope_info"] = {
@@ -161,29 +161,46 @@ def parse_arguments():
   project_instance.add_argument_project_instance(parser)
   project_id.add_argument_project_id(parser)
   regions.add_argument_region(parser)
-  parser.add_argument("-n", "--name", type=str, required=True,
+  parser.add_argument("-n",
+                      "--name",
+                      type=str,
+                      required=True,
                       help="unique name for the list")
-  parser.add_argument("-f", "--list_file", type=argparse.FileType("r"),
+  parser.add_argument("-f",
+                      "--list_file",
+                      type=argparse.FileType("r"),
                       required=True,
                       help="path of a file containing the list content")
-  parser.add_argument("-d", "--description", type=str,
+  parser.add_argument("-d",
+                      "--description",
+                      type=str,
                       help="description of the list")
-  parser.add_argument(
-      "-s", "--scope_name", type=str, help="data RBAC scope name for the list"
-  )
-  parser.add_argument("-t", "--syntax_type", type=str, default=None,
+  parser.add_argument("-s",
+                      "--scope_name",
+                      type=str,
+                      help="data RBAC scope name for the list")
+  parser.add_argument("-t",
+                      "--syntax_type",
+                      type=str,
+                      default=None,
                       choices=SYNTAX_TYPE_ENUM,
                       help="syntax type of the list, used for validation")
   add_delete_group = parser.add_mutually_exclusive_group()
-  add_delete_group.add_argument("--add", action="store_true",
+  add_delete_group.add_argument("--add",
+                                action="store_true",
                                 help="only append to the existing list")
-  add_delete_group.add_argument("--remove", action="store_true",
+  add_delete_group.add_argument("--remove",
+                                action="store_true",
                                 help="only remove from the existing list")
-  parser.add_argument("--force", action="store_true",
+  parser.add_argument("--force",
+                      action="store_true",
                       help="patch regardless of pre-check on changes to list")
-  parser.add_argument("--max_attempts", type=int, default=6,
+  parser.add_argument("--max_attempts",
+                      type=int,
+                      default=6,
                       help="how many times to attempt the patch operation")
-  parser.add_argument("--quiet", action="store_true",
+  parser.add_argument("--quiet",
+                      action="store_true",
                       help="only print the updated list")
   return parser.parse_args()
 
@@ -220,13 +237,16 @@ def get_current_state(auth_session, args):
   return curr_list, curr_json["revisionCreateTime"]
 
 
-def op_update_content_lines(operation_type, curr_list, content_lines,
+def op_update_content_lines(operation_type,
+                            curr_list,
+                            content_lines,
                             force=False):
   """Updates the content lines of a Reference List."""
   if operation_type == "add":
     seen = set(curr_list)
-    deduplicated_list = [x for x in content_lines
-                         if not (x in seen or seen.add(x))]
+    deduplicated_list = [
+        x for x in content_lines if not (x in seen or seen.add(x))
+    ]
     content_lines = curr_list + deduplicated_list
   elif operation_type == "remove":
     content_lines = [item for item in curr_list if item not in content_lines]
@@ -240,17 +260,15 @@ def main():
   args = parse_arguments()
   og_content_lines = read_content_lines(args.list_file)
 
-  auth_session = chronicle_auth.initialize_http_session(
-      args.credentials_file,
-      SCOPES
-  )
+  auth_session = chronicle_auth.initialize_http_session(args.credentials_file,
+                                                        SCOPES)
 
   operation_type = "add" if args.add else "remove" if args.remove else None
 
   curr_list, _ = get_current_state(auth_session, args)
 
-  content_lines = op_update_content_lines(
-      operation_type, curr_list, og_content_lines, args.force)
+  content_lines = op_update_content_lines(operation_type, curr_list,
+                                          og_content_lines, args.force)
 
   attempt, wait_time = 0, 1
   while attempt < args.max_attempts:
@@ -276,15 +294,13 @@ def main():
         print(f"Patch {operation_type or ''} success.")
       print(json.dumps(patched_json, indent=2))
       break
-    wait_time = exponential_backoff(attempt, args.max_attempts,
-                                    wait_time, args.quiet)
+    wait_time = exponential_backoff(attempt, args.max_attempts, wait_time,
+                                    args.quiet)
     # read and verify again in case other processes updated while waiting
     og_content_lines = read_content_lines(args.list_file)
     curr_list, _ = get_current_state(auth_session, args)
-    content_lines = op_update_content_lines(operation_type,
-                                            curr_list,
-                                            og_content_lines,
-                                            args.force)
+    content_lines = op_update_content_lines(operation_type, curr_list,
+                                            og_content_lines, args.force)
     attempt += 1
 
 
diff --git a/lists/v1alpha/patch_list_test.py b/lists/v1alpha/patch_list_test.py
index a957574..5496e0d 100644
--- a/lists/v1alpha/patch_list_test.py
+++ b/lists/v1alpha/patch_list_test.py
@@ -1,4 +1,4 @@
-# Copyright 2024 Google LLC
+# Copyright 2025 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -39,9 +39,15 @@ def test_extract_values_with_valid_entries(self, mocked_get_list):
 
     mocked_get_list.return_value = {
         "entries": [
-            {"value": 1},
-            {"value": 2},
-            {"value": 3},
+            {
+                "value": 1
+            },
+            {
+                "value": 2
+            },
+            {
+                "value": 3
+            },
         ],
         "revisionCreateTime": None,
     }
@@ -68,14 +74,18 @@ def test_get_current_state_with_empty_entries(self, mocked_get_list):
                      expected_result)
 
   @mock.patch("lists.v1alpha.get_list.get_list")
-  def test_get_current_state_with_some_entries_missing_value(self,
-                                                             mocked_get_list):
+  def test_get_current_state_with_some_entries_missing_value(
+      self, mocked_get_list):
     """Test when some "entries" are missing the "value" key."""
     mocked_get_list.return_value = {
         "entries": [
-            {"value": 1},
+            {
+                "value": 1
+            },
             {},  # Missing "value"
-            {"value": 3},
+            {
+                "value": 3
+            },
         ],
         "revisionCreateTime": None,
     }
@@ -106,8 +116,7 @@ def test_function_returns_correct_wait_time_without_jitter(self, mock_sleep):
   @mock.patch("random.uniform", return_value=0.5)
   def test_quiet_mode_suppresses_output(self, _, mock_sleep):
     with unittest.mock.patch(
-        "sys.stdout",
-        new_callable=unittest.mock.MagicMock) as mock_stdout:
+        "sys.stdout", new_callable=unittest.mock.MagicMock) as mock_stdout:
       patch_list.exponential_backoff(1, 3, quiet=True)
       mock_stdout.write.assert_not_called()
       mock_sleep.assert_called_once_with(2.0)
@@ -126,15 +135,14 @@ class PatchListOpUpdateContentLinesTest(unittest.TestCase):
   def test_add_with_duplicates(self):
     curr_list = ["item1", "item2"]
     content_lines = ["item2", "item3"]
-    result = patch_list.op_update_content_lines("add",
-                                                curr_list, content_lines)
+    result = patch_list.op_update_content_lines("add", curr_list, content_lines)
     self.assertEqual(result, ["item1", "item2", "item3"])
 
   def test_remove(self):
     curr_list = ["item1", "item2", "item3"]
     content_lines = ["item2"]  # Items to remove
-    result = patch_list.op_update_content_lines("remove",
-                                                curr_list, content_lines)
+    result = patch_list.op_update_content_lines("remove", curr_list,
+                                                content_lines)
     self.assertEqual(result, ["item1", "item3"])
 
   def test_no_change_no_force(self):
@@ -142,14 +150,17 @@ def test_no_change_no_force(self):
     content_lines = ["item1"]
     with self.assertRaises(SystemExit) as cm:  # Expect the exit behavior
       patch_list.op_update_content_lines("add",
-                                         curr_list, content_lines, force=False)
+                                         curr_list,
+                                         content_lines,
+                                         force=False)
     self.assertEqual(cm.exception.code, 0)
 
   def test_no_change_force(self):
     curr_list = ["item1"]
     content_lines = ["item1"]
     result = patch_list.op_update_content_lines("add",
-                                                curr_list, content_lines,
+                                                curr_list,
+                                                content_lines,
                                                 force=True)
     self.assertEqual(result, ["item1"])
 
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..4406e64
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,12 @@
+# pyproject.toml
+[tool.isort]
+profile = "google"
+use_parentheses = true
+known_first_party = ["google3", "google"]
+multi_line_output = 3
+
+[tool.yapf]
+based_on_style = "google"
+indent_width = 2
+column_limit = 80
+
diff --git a/requirements.txt b/requirements.txt
index 43c3784..9027d20 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,2 +1,4 @@
-google-auth
-requests < 2.32
+google-auth>=2.0.0
+requests>=2.0.0
+click>=8.0.0
+python-dotenv>=1.0.0
diff --git a/requirements_dev.txt b/requirements_dev.txt
new file mode 100644
index 0000000..aeb60d5
--- /dev/null
+++ b/requirements_dev.txt
@@ -0,0 +1,8 @@
+isort==6.0.1
+pre_commit==4.1.0
+mypy==1.15.0
+mypy-extensions==1.0.0
+setuptools>=25.0.1
+twine>=6.1.0
+wheel>=0.45.1
+yapf>=0.40.0build
diff --git a/sdk/__init__.py b/sdk/__init__.py
new file mode 100644
index 0000000..3d87ee0
--- /dev/null
+++ b/sdk/__init__.py
@@ -0,0 +1,14 @@
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
diff --git a/sdk/cli.py b/sdk/cli.py
new file mode 100644
index 0000000..8225447
--- /dev/null
+++ b/sdk/cli.py
@@ -0,0 +1,73 @@
+#!/usr/bin/env python3
+
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Chronicle API Command Line Interface.
+
+This module provides a unified CLI for interacting with Chronicle APIs.
+"""
+
+import click
+
+from sdk.commands import detect
+from sdk.commands import ingestion
+from sdk.commands import iocs
+from sdk.commands import lists
+from sdk.commands import search
+
+
+def add_common_options(func):
+  """Add common options to a command."""
+  func = click.option(
+      "--credentials-file",
+      required=True,
+      help="Path to service account credentials file.",
+  )(func)
+  func = click.option(
+      "--project-id",
+      required=True,
+      help="GCP project id or number to which the target instance belongs.",
+  )(func)
+  func = click.option(
+      "--project-instance",
+      required=True,
+      help="Customer ID (uuid with dashes) for the Chronicle instance.",
+  )(func)
+  func = click.option(
+      "--region",
+      required=True,
+      help="Region in which the target project is located.",
+  )(func)
+  return func
+
+
+@click.group()
+def cli():
+  """Chronicle API Command Line Interface.
+
+  This CLI provides access to Chronicle's detection, ingestion, IoCs,
+  search, and lists APIs.
+  """
+  pass
+
+
+# Add command groups
+cli.add_command(detect.detect)
+cli.add_command(ingestion.ingestion)
+cli.add_command(iocs.iocs)
+cli.add_command(lists.lists)
+cli.add_command(search.search)
+
+if __name__ == "__main__":
+  cli()
diff --git a/sdk/commands/__init__.py b/sdk/commands/__init__.py
new file mode 100644
index 0000000..3d87ee0
--- /dev/null
+++ b/sdk/commands/__init__.py
@@ -0,0 +1,14 @@
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
diff --git a/sdk/commands/common.py b/sdk/commands/common.py
new file mode 100644
index 0000000..b87d7af
--- /dev/null
+++ b/sdk/commands/common.py
@@ -0,0 +1,123 @@
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Common utilities for CLI commands."""
+
+import functools
+import os
+
+import click
+from dotenv import load_dotenv
+
+
+def get_env_value(key, default=None):
+  """Gets value from environment variable with Chronicle prefix.
+
+  Args:
+    key: The environment variable key (without CHRONICLE_ prefix).
+    default: Default value if environment variable is not set.
+
+  Returns:
+    The value of the environment variable or the default value.
+  """
+  return os.getenv(f"CHRONICLE_{key}", default)
+
+
+def add_common_options(func):
+  """Adds common CLI options to a command.
+
+  Adds standard Chronicle CLI options for region, project instance, project ID,
+  credentials file, and environment file. Values can be provided via command
+  line arguments or environment variables.
+
+  Args:
+    func: The function to wrap with common options.
+
+  Returns:
+    A decorated function that includes common Chronicle CLI options.
+  """
+
+  # Add CLI options first
+  @click.option(
+      "--region",
+      required=False,
+      help="Region in which the target project is located. Can also be set "
+      "via CHRONICLE_REGION env var.",
+  )
+  @click.option(
+      "--project-instance",
+      required=False,
+      help="Customer ID (uuid with dashes) for the Chronicle instance. "
+      "Can also be set via CHRONICLE_INSTANCE env var.",
+  )
+  @click.option(
+      "--project-id",
+      required=False,
+      help="GCP project id or number. Can also be set via CHRONICLE_PROJECT_ID "
+      "env var.",
+  )
+  @click.option(
+      "--credentials-file",
+      required=False,
+      help="Path to service account credentials file. Can also be set via "
+      "CHRONICLE_CREDENTIALS_FILE env var.",
+  )
+  @click.option(
+      "--env-file",
+      required=False,
+      help="Path to .env file containing configuration variables.",
+  )
+  @functools.wraps(func)
+  def wrapper(*args, **kwargs):
+    # Load environment variables from .env file
+    env_file = kwargs.pop("env_file", None)
+    if env_file:
+      load_dotenv(env_file)
+    else:
+      # Look for .env in the current working directory
+      cwd_env = os.path.join(os.getcwd(), ".env")
+      load_dotenv(cwd_env)
+
+    # Now validate required options
+    missing = []
+    # pylint: disable=line-too-long
+    if not kwargs.get("credentials_file") and not get_env_value(
+        "CREDENTIALS_FILE"):
+      # pylint: enable=line-too-long
+      missing.append("credentials-file (or CHRONICLE_CREDENTIALS_FILE)")
+    if not kwargs.get("project_id") and not get_env_value("PROJECT_ID"):
+      missing.append("project-id (or CHRONICLE_PROJECT_ID)")
+    if not kwargs.get("project_instance") and not get_env_value("INSTANCE"):
+      missing.append("project-instance (or CHRONICLE_INSTANCE)")
+    if not kwargs.get("region") and not get_env_value("REGION"):
+      missing.append("region (or CHRONICLE_REGION)")
+
+    if missing:
+      raise click.UsageError(
+          f"Missing required options: {', '.join(missing)}\n"
+          "These can be provided via command line options or environment "
+          "variables.")
+
+    # If options not provided via CLI, get from environment
+    if not kwargs.get("credentials_file"):
+      kwargs["credentials_file"] = get_env_value("CREDENTIALS_FILE")
+    if not kwargs.get("project_id"):
+      kwargs["project_id"] = get_env_value("PROJECT_ID")
+    if not kwargs.get("project_instance"):
+      kwargs["project_instance"] = get_env_value("INSTANCE")
+    if not kwargs.get("region"):
+      kwargs["region"] = get_env_value("REGION")
+
+    return func(*args, **kwargs)
+
+  return wrapper
diff --git a/sdk/commands/detect.py b/sdk/commands/detect.py
new file mode 100644
index 0000000..2296d51
--- /dev/null
+++ b/sdk/commands/detect.py
@@ -0,0 +1,490 @@
+#!/usr/bin/env python3
+
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Chronicle Detection API commands."""
+
+import json
+
+import click
+from common import chronicle_auth
+from detect.v1alpha import batch_update_curated_rule_set_deployments
+from detect.v1alpha import bulk_update_alerts
+from detect.v1alpha import create_retrohunt
+from detect.v1alpha import create_rule
+from detect.v1alpha import delete_rule
+from detect.v1alpha import enable_rule
+from detect.v1alpha import get_alert
+from detect.v1alpha import get_detection
+from detect.v1alpha import get_retrohunt
+from detect.v1alpha import get_rule
+from detect.v1alpha import list_detections
+from detect.v1alpha import list_errors
+from detect.v1alpha import list_rules
+from detect.v1alpha import update_alert
+from sdk.commands.common import add_common_options
+
+SCOPES = [
+    "https://www.googleapis.com/auth/cloud-platform",
+]
+
+
+@click.group()
+def detect():
+  """Detection API commands."""
+  pass
+
+
+# Alert Management Commands
+@detect.group()
+def alerts():
+  """Alert management commands."""
+  pass
+
+
+@alerts.command("get")
+@add_common_options
+@click.option(
+    "--alert-id",
+    required=True,
+    help="Identifier for the alert.",
+)
+@click.option(
+    "--include-detections",
+    is_flag=True,
+    help="Include non-alerting detections.",
+)
+def get_alert_cmd(credentials_file, project_id, project_instance, region,
+                  alert_id, include_detections):
+  """Get an alert by ID."""
+  auth_session = chronicle_auth.initialize_http_session(
+      credentials_file,
+      SCOPES,
+  )
+  alert = get_alert.get_alert(
+      auth_session,
+      project_id,
+      project_instance,
+      region,
+      alert_id,
+      include_detections,
+  )
+  print(json.dumps(alert, indent=2))
+
+
+@alerts.command("update")
+@add_common_options
+@click.option(
+    "--alert-id",
+    required=True,
+    help="Identifier for the alert.",
+)
+@click.option(
+    "--update-mask",
+    required=True,
+    help="Comma-separated list of fields to update.",
+)
+@click.option(
+    "--json-body",
+    required=True,
+    help="JSON string containing the update data.",
+)
+def update_alert_cmd(credentials_file, project_id, project_instance, region,
+                     alert_id, update_mask, json_body):
+  """Update an alert."""
+  auth_session = chronicle_auth.initialize_http_session(
+      credentials_file,
+      SCOPES,
+  )
+  result = update_alert.update_alert(
+      auth_session,
+      project_id,
+      project_instance,
+      region,
+      alert_id,
+      update_mask,
+      json_body,
+  )
+  print(json.dumps(result, indent=2))
+
+
+@alerts.command("bulk-update")
+@add_common_options
+@click.option(
+    "--a_filter",
+    required=True,
+    help="a_filter to select alerts to update.",
+)
+@click.option(
+    "--update-mask",
+    required=True,
+    help="Comma-separated list of fields to update.",
+)
+@click.option(
+    "--json-body",
+    required=True,
+    help="JSON string containing the update data.",
+)
+def bulk_update_alerts_cmd(credentials_file, project_id, project_instance,
+                           region, a_filter, update_mask, json_body):
+  """Bulk update alerts matching a filter."""
+  auth_session = chronicle_auth.initialize_http_session(
+      credentials_file,
+      SCOPES,
+  )
+  result = bulk_update_alerts.bulk_update_alerts(
+      auth_session,
+      project_id,
+      project_instance,
+      region,
+      a_filter,
+      update_mask,
+      json_body,
+  )
+  print(json.dumps(result, indent=2))
+
+
+# Detection Commands
+@detect.group()
+def detections():
+  """Detection management commands."""
+  pass
+
+
+@detections.command("get")
+@add_common_options
+@click.option(
+    "--detection-id",
+    required=True,
+    help="Identifier for the detection.",
+)
+@click.option(
+    "--rule-id",
+    required=True,
+    help="Identifier for the rule that created the detection.",
+)
+def get_detection_cmd(credentials_file, project_id, project_instance, region,
+                      detection_id, rule_id):
+  """Get a detection by ID."""
+  auth_session = chronicle_auth.initialize_http_session(
+      credentials_file,
+      SCOPES,
+  )
+  detection = get_detection.get_detection(
+      auth_session,
+      project_id,
+      project_instance,
+      region,
+      detection_id,
+      rule_id,
+  )
+  print(json.dumps(detection, indent=2))
+
+
+@detections.command("list")
+@add_common_options
+@click.option(
+    "--a_filter",
+    help="a_filter string for the list request.",
+)
+@click.option(
+    "--page-size",
+    type=int,
+    help="Maximum number of detections to return.",
+)
+@click.option(
+    "--page-token",
+    help="Page token from previous response.",
+)
+def list_detections_cmd(credentials_file, project_id, project_instance, region,
+                        a_filter, page_size, page_token):
+  """List detections."""
+  auth_session = chronicle_auth.initialize_http_session(
+      credentials_file,
+      SCOPES,
+  )
+  result = list_detections.list_detections(
+      auth_session,
+      region,
+      project_id,
+      project_instance,
+      a_filter,
+      page_size,
+      page_token,
+  )
+  print(json.dumps(result, indent=2))
+
+
+# Rule Management Commands
+@detect.group()
+def rules():
+  """Rule management commands."""
+  pass
+
+
+@rules.command("create")
+@add_common_options
+@click.option(
+    "--json-body",
+    required=True,
+    help="JSON string containing the rule definition.",
+)
+def create_rule_cmd(credentials_file, project_id, project_instance, region,
+                    json_body):
+  """Create a new rule."""
+  auth_session = chronicle_auth.initialize_http_session(
+      credentials_file,
+      SCOPES,
+  )
+  result = create_rule.create_rule(
+      auth_session,
+      project_id,
+      project_instance,
+      region,
+      json_body,
+  )
+  print(json.dumps(result, indent=2))
+
+
+@rules.command("get")
+@add_common_options
+@click.option(
+    "--rule-id",
+    required=True,
+    help="Identifier for the rule.",
+)
+def get_rule_cmd(credentials_file, project_id, project_instance, region,
+                 rule_id):
+  """Get a rule by ID."""
+  auth_session = chronicle_auth.initialize_http_session(
+      credentials_file,
+      SCOPES,
+  )
+  result = get_rule.get_rule(
+      auth_session,
+      project_id,
+      project_instance,
+      region,
+      rule_id,
+  )
+  print(json.dumps(result, indent=2))
+
+
+@rules.command("delete")
+@add_common_options
+@click.option(
+    "--rule-id",
+    required=True,
+    help="Identifier for the rule to delete.",
+)
+def delete_rule_cmd(credentials_file, project_id, project_instance, region,
+                    rule_id):
+  """Delete a rule."""
+  auth_session = chronicle_auth.initialize_http_session(
+      credentials_file,
+      SCOPES,
+  )
+  delete_rule.delete_rule(
+      auth_session,
+      project_id,
+      project_instance,
+      region,
+      rule_id,
+  )
+
+
+@rules.command("enable")
+@add_common_options
+@click.option(
+    "--rule-id",
+    required=True,
+    help="Identifier for the rule to enable.",
+)
+def enable_rule_cmd(credentials_file, project_id, project_instance, region,
+                    rule_id):
+  """Enable a rule."""
+  auth_session = chronicle_auth.initialize_http_session(
+      credentials_file,
+      SCOPES,
+  )
+  result = enable_rule.enable_rule(
+      auth_session,
+      project_id,
+      project_instance,
+      region,
+      rule_id,
+  )
+  print(json.dumps(result, indent=2))
+
+
+@rules.command("list")
+@add_common_options
+@click.option(
+    "--a_filter",
+    help="a_filter string for the list request.",
+)
+@click.option(
+    "--page-size",
+    type=int,
+    help="Maximum number of rules to return.",
+)
+@click.option(
+    "--page-token",
+    help="Page token from previous response.",
+)
+def list_rules_cmd(credentials_file, project_id, project_instance, region,
+                   a_filter, page_size, page_token):
+  """List rules."""
+  auth_session = chronicle_auth.initialize_http_session(
+      credentials_file,
+      SCOPES,
+  )
+  result = list_rules.list_rules(
+      auth_session,
+      project_id,
+      project_instance,
+      region,
+      a_filter,
+      page_size,
+      page_token,
+  )
+  print(json.dumps(result, indent=2))
+
+
+# Retrohunt Commands
+@detect.group()
+def retrohunts():
+  """Retrohunt management commands."""
+  pass
+
+
+@retrohunts.command("create")
+@add_common_options
+@click.option(
+    "--json-body",
+    required=True,
+    help="JSON string containing the retrohunt configuration.",
+)
+def create_retrohunt_cmd(credentials_file, project_id, project_instance, region,
+                         json_body):
+  """Create a new retrohunt."""
+  auth_session = chronicle_auth.initialize_http_session(
+      credentials_file,
+      SCOPES,
+  )
+  result = create_retrohunt.create_retrohunt(
+      auth_session,
+      project_id,
+      project_instance,
+      region,
+      json_body,
+  )
+  print(json.dumps(result, indent=2))
+
+
+@retrohunts.command("get")
+@add_common_options
+@click.option(
+    "--retrohunt-id",
+    required=True,
+    help="Identifier for the retrohunt.",
+)
+def get_retrohunt_cmd(credentials_file, project_id, project_instance, region,
+                      retrohunt_id):
+  """Get a retrohunt by ID."""
+  auth_session = chronicle_auth.initialize_http_session(
+      credentials_file,
+      SCOPES,
+  )
+  result = get_retrohunt.get_retrohunt(
+      auth_session,
+      project_id,
+      project_instance,
+      region,
+      retrohunt_id,
+  )
+  print(json.dumps(result, indent=2))
+
+
+# Error Management Commands
+@detect.group()
+def errors():
+  """Error management commands."""
+  pass
+
+
+@errors.command("list")
+@add_common_options
+@click.option(
+    "--a_filter",
+    help="a_filter string for the list request.",
+)
+@click.option(
+    "--page-size",
+    type=int,
+    help="Maximum number of errors to return.",
+)
+@click.option(
+    "--page-token",
+    help="Page token from previous response.",
+)
+def list_errors_cmd(credentials_file, project_id, project_instance, region,
+                    a_filter, page_size, page_token):
+  """List errors."""
+  auth_session = chronicle_auth.initialize_http_session(
+      credentials_file,
+      SCOPES,
+  )
+  result = list_errors.list_errors(
+      auth_session,
+      project_id,
+      project_instance,
+      region,
+      a_filter,
+      page_size,
+      page_token,
+  )
+  print(json.dumps(result, indent=2))
+
+
+# Rule Set Deployment Commands
+@detect.group()
+def rulesets():
+  """Rule set deployment commands."""
+  pass
+
+
+@rulesets.command("batch-update")
+@add_common_options
+@click.option(
+    "--json-body",
+    required=True,
+    help="JSON string containing the rule set deployment updates.",
+)
+def batch_update_rule_sets_cmd(credentials_file, project_id, project_instance,
+                               region, json_body):
+  """Batch update rule set deployments."""
+  auth_session = chronicle_auth.initialize_http_session(
+      credentials_file,
+      SCOPES,
+  )
+  # pylint: disable-next=line-too-long
+  result = batch_update_curated_rule_set_deployments.batch_update_curated_rule_set_deployments(
+      auth_session,
+      project_id,
+      project_instance,
+      region,
+      json_body,
+  )
+  print(json.dumps(result, indent=2))
diff --git a/sdk/commands/ingestion.py b/sdk/commands/ingestion.py
new file mode 100644
index 0000000..6482134
--- /dev/null
+++ b/sdk/commands/ingestion.py
@@ -0,0 +1,99 @@
+#!/usr/bin/env python3
+
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Chronicle Ingestion API commands."""
+
+import click
+from common import chronicle_auth
+
+from ingestion.v1alpha import event_import
+from ingestion.v1alpha import events_batch_get
+from ingestion.v1alpha import events_get
+
+SCOPES = [
+    "https://www.googleapis.com/auth/cloud-platform",
+]
+
+
+@click.group()
+def ingestion():
+  """Ingestion API commands."""
+  pass
+
+
+@ingestion.command("import-events")
+@click.option(
+    "--json-events",
+    required=True,
+    help="Events in (serialized) JSON format.",
+)
+@click.pass_context
+def import_events_cmd(ctx, json_events):
+  """Import events into Chronicle."""
+  auth_session = chronicle_auth.initialize_http_session(
+      ctx.obj["credentials_file"],
+      SCOPES,
+  )
+  event_import.import_events(
+      auth_session,
+      ctx.obj["project_id"],
+      ctx.obj["project_instance"],
+      ctx.obj["region"],
+      json_events,
+  )
+
+
+@ingestion.command("get-event")
+@click.option(
+    "--event-id",
+    required=True,
+    help="The ID of the event to retrieve.",
+)
+@click.pass_context
+def get_event_cmd(ctx, event_id):
+  """Get event details by ID."""
+  auth_session = chronicle_auth.initialize_http_session(
+      ctx.obj["credentials_file"],
+      SCOPES,
+  )
+  events_get.get_event(
+      auth_session,
+      ctx.obj["project_id"],
+      ctx.obj["project_instance"],
+      ctx.obj["region"],
+      event_id,
+  )
+
+
+@ingestion.command("batch-get-events")
+@click.option(
+    "--event-ids",
+    required=True,
+    help="JSON string containing a list of event IDs to retrieve.",
+)
+@click.pass_context
+def batch_get_events_cmd(ctx, event_ids):
+  """Batch get events by IDs."""
+  auth_session = chronicle_auth.initialize_http_session(
+      ctx.obj["credentials_file"],
+      SCOPES,
+  )
+  events_batch_get.batch_get_events(
+      auth_session,
+      ctx.obj["project_id"],
+      ctx.obj["project_instance"],
+      ctx.obj["region"],
+      event_ids,
+  )
diff --git a/sdk/commands/iocs.py b/sdk/commands/iocs.py
new file mode 100644
index 0000000..603b751
--- /dev/null
+++ b/sdk/commands/iocs.py
@@ -0,0 +1,162 @@
+#!/usr/bin/env python3
+
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Chronicle IoCs API commands."""
+
+import json
+
+import click
+from common import chronicle_auth
+
+from iocs.v1alpha import batch_get_iocs
+from iocs.v1alpha import get_ioc
+from iocs.v1alpha import get_ioc_state
+from sdk.commands.common import add_common_options
+
+SCOPES = [
+    "https://www.googleapis.com/auth/cloud-platform",
+]
+
+
+@click.group()
+def iocs():
+  """IoCs API commands."""
+  pass
+
+
+@iocs.command("batch-get")
+@add_common_options
+@click.option(
+    "--ioc-values",
+    required=True,
+    help="JSON array of IoC values to retrieve.",
+)
+@click.option(
+    "--ioc-type",
+    type=click.Choice([
+        "IOC_TYPE_UNSPECIFIED",
+        "DOMAIN",
+        "IP",
+        "FILE_HASH",
+        "URL",
+        "USER_EMAIL",
+        "MUTEX",
+        "FILE_HASH_MD5",
+        "FILE_HASH_SHA1",
+        "FILE_HASH_SHA256",
+        "IOC_TYPE_RESOURCE",
+    ]),
+    required=True,
+    help="Type of IoCs being requested.",
+)
+def batch_get_iocs_cmd(credentials_file, project_id, project_instance, region,
+                       ioc_values, ioc_type):
+  """Get multiple IoCs by their values."""
+  auth_session = chronicle_auth.initialize_http_session(
+      credentials_file,
+      SCOPES,
+  )
+
+  ioc_values_list = json.loads(ioc_values)
+
+  result = batch_get_iocs.batch_get_iocs(
+      auth_session,
+      project_id,
+      project_instance,
+      region,
+      ioc_values_list,
+      ioc_type,
+  )
+  print(json.dumps(result, indent=2))
+
+
+@iocs.command("get")
+@add_common_options
+@click.option(
+    "--ioc-value",
+    required=True,
+    help="Value of the IoC to retrieve.",
+)
+@click.option(
+    "--ioc-type",
+    type=click.Choice([
+        "IOC_TYPE_UNSPECIFIED",
+        "DOMAIN",
+        "IP",
+        "FILE_HASH",
+        "URL",
+        "USER_EMAIL",
+        "MUTEX",
+        "FILE_HASH_MD5",
+        "FILE_HASH_SHA1",
+        "FILE_HASH_SHA256",
+        "IOC_TYPE_RESOURCE",
+    ]),
+    required=True,
+    help="Type of IoC being requested.",
+)
+def get_ioc_cmd(credentials_file, project_id, project_instance, region,
+                ioc_value, ioc_type):
+  """Get a single IoC by its value."""
+  auth_session = chronicle_auth.initialize_http_session(
+      credentials_file,
+      SCOPES,
+  )
+
+  result = get_ioc.get_ioc(
+      auth_session,
+      project_id,
+      project_instance,
+      region,
+      ioc_value,
+      ioc_type,
+  )
+  print(json.dumps(result, indent=2))
+
+
+@iocs.command("get-state")
+@add_common_options
+@click.option(
+    "--ioc-value",
+    required=True,
+    help="Value of the IoC to get state for.",
+)
+@click.option(
+    "--ioc-type",
+    type=click.Choice([
+        "IOC_TYPE_UNSPECIFIED", "DOMAIN", "IP", "FILE_HASH", "URL",
+        "USER_EMAIL", "MUTEX", "FILE_HASH_MD5", "FILE_HASH_SHA1",
+        "FILE_HASH_SHA256", "IOC_TYPE_RESOURCE"
+    ]),
+    required=True,
+    help="Type of IoC being requested.",
+)
+def get_ioc_state_cmd(credentials_file, project_id, project_instance, region,
+                      ioc_value, ioc_type):
+  """Get the state of an IoC by its value."""
+  auth_session = chronicle_auth.initialize_http_session(
+      credentials_file,
+      SCOPES,
+  )
+
+  result = get_ioc_state.get_ioc_state(
+      auth_session,
+      project_id,
+      project_instance,
+      region,
+      ioc_value,
+      ioc_type,
+  )
+  print(json.dumps(result, indent=2))
diff --git a/sdk/commands/lists.py b/sdk/commands/lists.py
new file mode 100644
index 0000000..ce2ef3f
--- /dev/null
+++ b/sdk/commands/lists.py
@@ -0,0 +1,140 @@
+#!/usr/bin/env python3
+
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Chronicle Lists API commands."""
+
+import json
+
+import click
+from common import chronicle_auth
+
+from lists.v1alpha import create_list
+from lists.v1alpha import get_list
+from lists.v1alpha import patch_list
+from sdk.commands.common import add_common_options
+
+SCOPES = [
+    "https://www.googleapis.com/auth/cloud-platform",
+]
+
+
+@click.group()
+def lists():
+  """Lists API commands."""
+  pass
+
+
+@lists.command("create")
+@add_common_options
+@click.option(
+    "--name",
+    required=True,
+    help="Name of the list to create.",
+)
+@click.option(
+    "--description",
+    help="Description of the list.",
+)
+@click.option(
+    "--lines",
+    required=True,
+    help="JSON array of strings to add to the list.",
+)
+def create_list_cmd(credentials_file, project_id, project_instance, region,
+                    name, description, lines):
+  """Create a new list."""
+  auth_session = chronicle_auth.initialize_http_session(
+      credentials_file,
+      SCOPES,
+  )
+  result = create_list.create_list(
+      auth_session,
+      project_id,
+      project_instance,
+      region,
+      name,
+      description,
+      lines,
+  )
+  print(json.dumps(result, indent=2))
+
+
+@lists.command("get")
+@add_common_options
+@click.option(
+    "--list-id",
+    required=True,
+    help="ID of the list to retrieve.",
+)
+def get_list_cmd(credentials_file, project_id, project_instance, region,
+                 list_id):
+  """Get a list by ID."""
+  auth_session = chronicle_auth.initialize_http_session(
+      credentials_file,
+      SCOPES,
+  )
+  result = get_list.get_list(
+      auth_session,
+      project_id,
+      project_instance,
+      region,
+      list_id,
+  )
+  print(json.dumps(result, indent=2))
+
+
+@lists.command("patch")
+@add_common_options
+@click.option(
+    "--list-id",
+    required=True,
+    help="ID of the list to update.",
+)
+@click.option(
+    "--description",
+    help="New description for the list.",
+)
+@click.option(
+    "--lines-to-add",
+    help="JSON array of strings to add to the list.",
+)
+@click.option(
+    "--lines-to-remove",
+    help="JSON array of strings to remove from the list.",
+)
+def patch_list_cmd(credentials_file, project_id, project_instance, region,
+                   list_id, description, lines_to_add, lines_to_remove):
+  """Update an existing list."""
+  auth_session = chronicle_auth.initialize_http_session(
+      credentials_file,
+      SCOPES,
+  )
+  result = patch_list.patch_list(
+      auth_session,
+      project_id,
+      project_instance,
+      region,
+      list_id,
+      description,
+      lines_to_add,
+      lines_to_remove,
+  )
+  print(json.dumps(result, indent=2))
+
+
+
+
+
+
diff --git a/sdk/commands/search.py b/sdk/commands/search.py
new file mode 100644
index 0000000..0fb8081
--- /dev/null
+++ b/sdk/commands/search.py
@@ -0,0 +1,204 @@
+#!/usr/bin/env python3
+
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Chronicle Search API commands."""
+
+import click
+from common import chronicle_auth
+
+from search.v1alpha import asset_events_find
+from search.v1alpha import raw_logs_find
+from search.v1alpha import search_query_get
+from search.v1alpha import udm_events_find
+
+SCOPES = [
+    "https://www.googleapis.com/auth/cloud-platform",
+]
+
+
+@click.group()
+def search():
+  """Search API commands."""
+  pass
+
+
+@search.command("find-asset-events")
+@click.option(
+    "--asset-indicator",
+    required=True,
+    help="The asset indicator to search for.",
+)
+@click.option(
+    "--start-time",
+    required=True,
+    help="RFC3339 formatted timestamp for the start time.",
+)
+@click.option(
+    "--end-time",
+    required=True,
+    help="RFC3339 formatted timestamp for the end time.",
+)
+@click.option(
+    "--page-size",
+    type=int,
+    help="Optional maximum number of results to return.",
+)
+@click.option(
+    "--page-token",
+    help="Optional page token from a previous response.",
+)
+@click.pass_context
+def find_asset_events_cmd(ctx, asset_indicator, start_time, end_time, page_size,
+                          page_token):
+  """Find asset events within a time range."""
+  auth_session = chronicle_auth.initialize_http_session(
+      ctx.obj["credentials_file"],
+      SCOPES,
+  )
+  asset_events_find.find_asset_events(
+      auth_session,
+      ctx.obj["project_id"],
+      ctx.obj["project_instance"],
+      ctx.obj["region"],
+      asset_indicator,
+      start_time,
+      end_time,
+      page_size,
+      page_token,
+  )
+
+
+@search.command("find-raw-logs")
+@click.option(
+    "--query",
+    required=True,
+    help="Search parameters that expand or restrict the search.",
+)
+@click.option(
+    "--batch-tokens",
+    multiple=True,
+    help="Optional list of tokens that should be downloaded.",
+)
+@click.option(
+    "--log-ids",
+    multiple=True,
+    help="Optional list of raw log ids that should be downloaded.",
+)
+@click.option(
+    "--regex-search",
+    is_flag=True,
+    help="Treat query as regex.",
+)
+@click.option(
+    "--case-sensitive",
+    is_flag=True,
+    help="Make search case-sensitive.",
+)
+@click.option(
+    "--max-response-size",
+    type=int,
+    help="Optional maximum response size in bytes.",
+)
+@click.pass_context
+def find_raw_logs_cmd(ctx, query, batch_tokens, log_ids, regex_search,
+                      case_sensitive, max_response_size):
+  """Find raw logs based on search criteria."""
+  auth_session = chronicle_auth.initialize_http_session(
+      ctx.obj["credentials_file"],
+      SCOPES,
+  )
+  raw_logs_find.find_raw_logs(
+      auth_session,
+      ctx.obj["project_id"],
+      ctx.obj["project_instance"],
+      ctx.obj["region"],
+      query,
+      list(batch_tokens) if batch_tokens else None,
+      list(log_ids) if log_ids else None,
+      regex_search,
+      case_sensitive,
+      max_response_size,
+  )
+
+
+@search.command("find-udm-events")
+@click.option(
+    "--tokens",
+    multiple=True,
+    help=
+    "Optional list of tokens, with each token referring to a group of "
+    "UDM/Entity events.",
+)
+@click.option(
+    "--event-ids",
+    multiple=True,
+    help="Optional list of UDM/Entity event ids that should be returned.",
+)
+@click.option(
+    "--return-unenriched-data",
+    is_flag=True,
+    help="Return unenriched data.",
+)
+@click.option(
+    "--return-all-events-for-log",
+    is_flag=True,
+    help="Return all events generated from the ingested log.",
+)
+@click.pass_context
+def find_udm_events_cmd(ctx, tokens, event_ids, return_unenriched_data,
+                        return_all_events_for_log):
+  """Find UDM events based on tokens or event IDs."""
+  auth_session = chronicle_auth.initialize_http_session(
+      ctx.obj["credentials_file"],
+      SCOPES,
+  )
+  udm_events_find.find_udm_events(
+      auth_session,
+      ctx.obj["project_id"],
+      ctx.obj["project_instance"],
+      ctx.obj["region"],
+      list(tokens) if tokens else None,
+      list(event_ids) if event_ids else None,
+      return_unenriched_data,
+      return_all_events_for_log,
+  )
+
+
+@search.command("get-search-query")
+@click.option(
+    "--user-id",
+    required=True,
+    help="ID of the user who owns the search query.",
+)
+@click.option(
+    "--query-id",
+    required=True,
+    help="ID of the search query to retrieve.",
+)
+@click.pass_context
+def get_search_query_cmd(ctx, user_id, query_id):
+  """Get a search query by ID."""
+  auth_session = chronicle_auth.initialize_http_session(
+      ctx.obj["credentials_file"],
+      SCOPES,
+  )
+  search_query_get.get_search_query(
+      auth_session,
+      ctx.obj["project_id"],
+      ctx.obj["project_instance"],
+      ctx.obj["region"],
+      user_id,
+      query_id,
+  )
diff --git a/search/v1alpha/__init__.py b/search/v1alpha/__init__.py
new file mode 100644
index 0000000..af1d471
--- /dev/null
+++ b/search/v1alpha/__init__.py
@@ -0,0 +1,14 @@
+# Copyright 2021 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
diff --git a/search/v1alpha/asset_events_find.py b/search/v1alpha/asset_events_find.py
new file mode 100644
index 0000000..69024a6
--- /dev/null
+++ b/search/v1alpha/asset_events_find.py
@@ -0,0 +1,170 @@
+#!/usr/bin/env python3
+
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# pylint: disable=line-too-long
+r"""Executable and reusable v1alpha API sample for finding asset events in Chronicle.
+
+API reference:
+https://cloud.google.com/chronicle/docs/reference/rest/v1alpha/projects.locations.instances.legacy/legacyFindAssetEvents
+"""
+# pylint: enable=line-too-long
+
+import argparse
+import datetime
+import json
+from typing import Optional
+
+from common import chronicle_auth
+from common import project_id
+from common import project_instance
+from common import regions
+from google.auth.transport import requests
+
+CHRONICLE_API_BASE_URL = "https://chronicle.googleapis.com"
+SCOPES = [
+    "https://www.googleapis.com/auth/cloud-platform",
+]
+
+DEFAULT_MAX_RESULTS = 10000
+MAX_RESULTS_LIMIT = 250000
+
+
+def find_asset_events(http_session: requests.AuthorizedSession,
+                      proj_id: str,
+                      proj_instance: str,
+                      proj_region: str,
+                      asset_indicator: str,
+                      start_time: str,
+                      end_time: str,
+                      reference_time: Optional[str] = None,
+                      max_results: Optional[int] = None) -> None:
+  """Find asset events in Chronicle using the Legacy Find Asset Events API.
+
+  Args:
+    http_session: Authorized session for HTTP requests.
+    proj_id: GCP project id or number to which the target instance belongs.
+    proj_instance: Customer ID (uuid with dashes) for the instance.
+    proj_region: region in which the target project is located.
+    asset_indicator: JSON str containing the asset indicator to search for.
+    start_time: Start time in RFC3339 format (e.g., "2024-01-01T00:00:00Z").
+    end_time: End time in RFC3339 format (e.g., "2024-01-02T00:00:00Z").
+    reference_time: Optional reference time in RFC3339 format for
+      asset aliasing.
+    max_results: Optional maximum number of results to return
+      (default: 10000, max: 250000).
+
+  Raises:
+    requests.exceptions.HTTPError: HTTP request resulted in an error
+        (response.status_code >= 400).
+    ValueError: If the time format is invalid.
+
+  Requires the following IAM permission on the instance resource:
+  chronicle.legacies.legacyFindAssetEvents
+  """
+  # Validate and parse the times to ensure they're in RFC3339 format
+  for time_str in [start_time, end_time, reference_time
+                  ] if reference_time else [start_time, end_time]:
+    try:
+      datetime.datetime.strptime(time_str, "%Y-%m-%dT%H:%M:%SZ")
+    except ValueError as e:
+      if "does not match format" in str(e):
+        raise ValueError(f"Time '{time_str}' must be in RFC3339 format "
+                         "(e.g., '2024-01-01T00:00:00Z')") from e
+      raise
+
+  base_url_with_region = regions.url_always_prepend_region(
+      CHRONICLE_API_BASE_URL, proj_region)
+  # pylint: disable=line-too-long
+  instance = f"projects/{proj_id}/locations/{proj_region}/instances/{proj_instance}"
+  url = f"{base_url_with_region}/v1alpha/{instance}/legacy:legacyFindAssetEvents"
+  # pylint: enable=line-too-long
+
+  # Build query parameters
+  params = [
+      f"assetIndicator={asset_indicator}", f"timeRange.startTime={start_time}",
+      f"timeRange.endTime={end_time}"
+  ]
+
+  if reference_time:
+    params.append(f"referenceTime={reference_time}")
+
+  if max_results:
+    # Ensure max_results is within bounds
+    max_results = min(max(1, max_results), MAX_RESULTS_LIMIT)
+    params.append(f"maxResults={max_results}")
+
+  url = f"{url}?{'&'.join(params)}"
+
+  response = http_session.request("GET", url)
+  if response.status_code >= 400:
+    print(response.text)
+  response.raise_for_status()
+
+  result = response.json()
+  print(json.dumps(result, indent=2))
+
+  if result.get("more_data_available"):
+    print("\nWarning: More data is available but was not returned due to "
+          "maxResults limit.")
+
+  if result.get("uri"):
+    print("\nBackstory UI URLs:")
+    for uri in result["uri"]:
+      print(f"  {uri}")
+
+
+if __name__ == "__main__":
+  parser = argparse.ArgumentParser()
+  # common
+  chronicle_auth.add_argument_credentials_file(parser)
+  project_instance.add_argument_project_instance(parser)
+  project_id.add_argument_project_id(parser)
+  regions.add_argument_region(parser)
+  # local
+  parser.add_argument("--asset_indicator",
+                      type=str,
+                      required=True,
+                      help="JSON string containing the asset indicator "
+                      "(e.g., '{\"hostname\": \"example.com\"}')")
+  parser.add_argument(
+      "--start_time",
+      type=str,
+      required=True,
+      help="Start time in RFC3339 format (e.g., '2024-01-01T00:00:00Z')")
+  parser.add_argument(
+      "--end_time",
+      type=str,
+      required=True,
+      help="End time in RFC3339 format (e.g., '2024-01-02T00:00:00Z')")
+  parser.add_argument(
+      "--reference_time",
+      type=str,
+      help="Optional reference time in RFC3339 format for asset aliasing")
+  parser.add_argument(
+      "--max_results",
+      type=int,
+      help="Maximum number of results to return "
+      f"(default: {DEFAULT_MAX_RESULTS}, max: {MAX_RESULTS_LIMIT})")
+
+  args = parser.parse_args()
+
+  auth_session = chronicle_auth.initialize_http_session(
+      args.credentials_file,
+      SCOPES,
+  )
+  find_asset_events(auth_session, args.project_id, args.project_instance,
+                    args.region, args.asset_indicator, args.start_time,
+                    args.end_time, args.reference_time, args.max_results)
diff --git a/search/v1alpha/raw_logs_find.py b/search/v1alpha/raw_logs_find.py
new file mode 100644
index 0000000..70e4843
--- /dev/null
+++ b/search/v1alpha/raw_logs_find.py
@@ -0,0 +1,162 @@
+#!/usr/bin/env python3
+
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# pylint: disable=line-too-long
+r"""Executable and reusable v1alpha API sample for finding raw logs in Chronicle.
+
+API reference:
+https://cloud.google.com/chronicle/docs/reference/rest/v1alpha/projects.locations.instances.legacy/legacyFindRawLogs
+"""
+# pylint: enable=line-too-long
+
+import argparse
+import json
+from typing import List, Optional
+
+from common import chronicle_auth
+from common import project_id
+from common import project_instance
+from common import regions
+from google.auth.transport import requests
+
+CHRONICLE_API_BASE_URL = "https://chronicle.googleapis.com"
+SCOPES = [
+    "https://www.googleapis.com/auth/cloud-platform",
+]
+
+DEFAULT_MAX_RESPONSE_SIZE = 52428800  # 50MiB in bytes
+
+
+def find_raw_logs(http_session: requests.AuthorizedSession,
+                  proj_id: str,
+                  proj_instance: str,
+                  proj_region: str,
+                  query: str,
+                  batch_tokens: Optional[List[str]] = None,
+                  log_ids: Optional[List[str]] = None,
+                  regex_search: bool = False,
+                  case_sensitive: bool = False,
+                  max_response_size: Optional[int] = None) -> None:
+  # pylint: disable=line-too-long
+  """Find raw logs in Chronicle using the Legacy Find Raw Logs API.
+
+  Args:
+    http_session: Authorized session for HTTP requests.
+    proj_id: GCP project id or number to which the target instance belongs.
+    proj_instance: Customer ID (uuid with dashes) for the Chronicle instance.
+    proj_region: region in which the target project is located.
+    query: Required search parameters that expand or restrict the search.
+    batch_tokens: Optional list of tokens that should be downloaded.
+    log_ids: Optional list of raw log ids that should be downloaded.
+        If both batch_tokens and log_ids are provided, batch_tokens will be discarded.
+    regex_search: Optional boolean to treat query as regex. Default is False.
+    case_sensitive: Optional boolean for case-sensitive search. Default is False.
+    max_response_size: Optional maximum response size in bytes. Default is 50MiB.
+
+  Raises:
+    requests.exceptions.HTTPError: HTTP request resulted in an error
+        (response.status_code >= 400).
+
+  Requires the following IAM permission on the instance resource:
+  chronicle.legacies.legacyFindRawLogs
+  """
+  # pylint: enable=line-too-long
+  base_url_with_region = regions.url_always_prepend_region(
+      CHRONICLE_API_BASE_URL, proj_region)
+  # pylint: disable-next=line-too-long
+  instance = f"projects/{proj_id}/locations/{proj_region}/instances/{proj_instance}"
+  url = f"{base_url_with_region}/v1alpha/{instance}/legacy:legacyFindRawLogs"
+
+  # Build query parameters
+  params = [f"query={query}"]
+  if batch_tokens and not log_ids:  # log_ids take precedence over batch_tokens
+    for token in batch_tokens:
+      params.append(f"batchToken={token}")
+  if log_ids:
+    for log_id in log_ids:
+      params.append(f"ids={log_id}")
+  if regex_search:
+    params.append("regexSearch=true")
+  if case_sensitive:
+    params.append("caseSensitive=true")
+  if max_response_size:
+    params.append(f"maxResponseByteSize={max_response_size}")
+
+  url = f"{url}?{'&'.join(params)}"
+
+  response = http_session.request("GET", url)
+  if response.status_code >= 400:
+    print(response.text)
+  response.raise_for_status()
+
+  result = response.json()
+  print(json.dumps(result, indent=2))
+
+  if result.get("too_many_results"):
+    print("\nWarning: Some results were omitted due to too many matches.")
+
+
+if __name__ == "__main__":
+  parser = argparse.ArgumentParser()
+  # common
+  chronicle_auth.add_argument_credentials_file(parser)
+  project_instance.add_argument_project_instance(parser)
+  project_id.add_argument_project_id(parser)
+  regions.add_argument_region(parser)
+  # local
+  parser.add_argument(
+      "--query",
+      type=str,
+      required=True,
+      help="Search parameters that expand or restrict the search")
+  parser.add_argument(
+      "--batch_tokens",
+      type=str,
+      help=
+      'JSON string containing a list of batch tokens '
+      ' (e.g., \'["token1", "token2"]\')'
+  )
+  parser.add_argument(
+      "--log_ids",
+      type=str,
+      help=
+      'JSON string containing a list of raw log IDs (e.g., \'["id1", "id2"]\')')
+  parser.add_argument("--regex_search",
+                      action="store_true",
+                      help="Whether to treat the query as a regex pattern")
+  parser.add_argument("--case_sensitive",
+                      action="store_true",
+                      help="Whether to perform a case-sensitive search")
+  parser.add_argument(
+      "--max_response_size",
+      type=int,
+      help=
+      f"Maximum response size in bytes (default: {DEFAULT_MAX_RESPONSE_SIZE})")
+
+  args = parser.parse_args()
+
+  # Convert JSON strings to lists if provided
+  batch_tokens_list = json.loads(
+      args.batch_tokens) if args.batch_tokens else None
+  log_ids_list = json.loads(args.log_ids) if args.log_ids else None
+
+  auth_session = chronicle_auth.initialize_http_session(
+      args.credentials_file,
+      SCOPES,
+  )
+  find_raw_logs(auth_session, args.project_id, args.project_instance,
+                args.region, args.query, batch_tokens_list, log_ids_list,
+                args.regex_search, args.case_sensitive, args.max_response_size)
diff --git a/search/v1alpha/search_query_get.py b/search/v1alpha/search_query_get.py
new file mode 100644
index 0000000..fd41d22
--- /dev/null
+++ b/search/v1alpha/search_query_get.py
@@ -0,0 +1,97 @@
+#!/usr/bin/env python3
+
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# pylint: disable=line-too-long
+r"""Executable and reusable v1alpha API sample for getting a search query in Chronicle.
+
+API reference:
+https://cloud.google.com/chronicle/docs/reference/rest/v1alpha/projects.locations.instances.users.searchQueries/get
+"""
+# pylint: enable=line-too-long
+
+import argparse
+
+from common import chronicle_auth
+from common import project_id
+from common import project_instance
+from common import regions
+from google.auth.transport import requests
+
+CHRONICLE_API_BASE_URL = "https://chronicle.googleapis.com"
+SCOPES = [
+    "https://www.googleapis.com/auth/cloud-platform",
+]
+
+
+def get_search_query(http_session: requests.AuthorizedSession, proj_id: str,
+                     proj_instance: str, proj_region: str, user_id: str,
+                     query_id: str) -> None:
+  """Get a search query by ID from Chronicle.
+
+  Args:
+    http_session: Authorized session for HTTP requests.
+    proj_id: GCP project id or number to which the target instance belongs.
+    proj_instance: Customer ID (uuid with dashes) for the instance.
+    proj_region: region in which the target project is located.
+    user_id: ID of the user who owns the search query.
+    query_id: ID of the search query to retrieve.
+
+  Raises:
+    requests.exceptions.HTTPError: HTTP request resulted in an error
+      (response.status_code >= 400).
+
+  Requires the following IAM permission on the instance resource:
+  chronicle.searchQueries.get
+  """
+  base_url_with_region = regions.url_always_prepend_region(
+      CHRONICLE_API_BASE_URL, proj_region)
+  # pylint: disable=line-too-long
+  instance = f"projects/{proj_id}/locations/{proj_region}/instances/{proj_instance}"
+  url = f"{base_url_with_region}/v1alpha/{instance}/users/{user_id}/searchQueries/{query_id}"
+  # pylint: enable=line-too-long
+
+  response = http_session.request("GET", url)
+  if response.status_code >= 400:
+    print(response.text)
+  response.raise_for_status()
+  print(response.text)
+
+
+if __name__ == "__main__":
+  parser = argparse.ArgumentParser()
+  # common
+  chronicle_auth.add_argument_credentials_file(parser)
+  project_instance.add_argument_project_instance(parser)
+  project_id.add_argument_project_id(parser)
+  regions.add_argument_region(parser)
+  # local
+  parser.add_argument("--user_id",
+                      type=str,
+                      required=True,
+                      help="ID of the user who owns the search query")
+  parser.add_argument("--query_id",
+                      type=str,
+                      required=True,
+                      help="ID of the search query to retrieve")
+
+  args = parser.parse_args()
+
+  auth_session = chronicle_auth.initialize_http_session(
+      args.credentials_file,
+      SCOPES,
+  )
+  get_search_query(auth_session, args.project_id, args.project_instance,
+                   args.region, args.user_id, args.query_id)
diff --git a/search/v1alpha/udm_events_find.py b/search/v1alpha/udm_events_find.py
new file mode 100644
index 0000000..1ff6eeb
--- /dev/null
+++ b/search/v1alpha/udm_events_find.py
@@ -0,0 +1,158 @@
+#!/usr/bin/env python3
+
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# pylint: disable=line-too-long
+r"""Executable and reusable v1alpha API sample for finding UDM events in Chronicle.
+
+Usage:
+  python -m search.v1alpha.udm_events_find \
+    --project_id=<PROJECT_ID> \
+    --project_instance=<PROJECT_INSTANCE> \
+    --region=<REGION> \
+    --tokens='["token1", "token2"]'
+
+  # Or using event IDs:
+  python -m search.v1alpha.udm_events_find \
+    --project_id=<PROJECT_ID> \
+    --project_instance=<PROJECT_INSTANCE> \
+    --region=<REGION> \
+    --event_ids='["id1", "id2"]'
+
+API reference:
+https://cloud.google.com/chronicle/docs/reference/rest/v1alpha/projects.locations.instances.legacy/legacyFindUdmEvents
+"""
+# pylint: enable=line-too-long
+
+import argparse
+import json
+from typing import List, Optional
+
+from common import chronicle_auth
+from common import project_id
+from common import project_instance
+from common import regions
+from google.auth.transport import requests
+
+CHRONICLE_API_BASE_URL = "https://chronicle.googleapis.com"
+SCOPES = [
+    "https://www.googleapis.com/auth/cloud-platform",
+]
+
+
+def find_udm_events(http_session: requests.AuthorizedSession,
+                    proj_id: str,
+                    proj_instance: str,
+                    proj_region: str,
+                    tokens: Optional[List[str]] = None,
+                    event_ids: Optional[List[str]] = None,
+                    return_unenriched_data: bool = False,
+                    return_all_events_for_log: bool = False) -> None:
+  # pylint: disable=line-too-long
+  """Find UDM events in Chronicle using the Legacy Find UDM Events API.
+
+  Args:
+    http_session: Authorized session for HTTP requests.
+    proj_id: GCP project id or number to which the target instance belongs.
+    proj_instance: Customer ID (uuid with dashes) for the Chronicle instance.
+    proj_region: region in which the target project is located.
+    tokens: Optional list of tokens, with each token referring to a group of UDM/Entity events.
+    event_ids: Optional list of UDM/Entity event ids that should be returned.
+      If both tokens and event_ids are provided, tokens will be discarded.
+    return_unenriched_data: Optional boolean to return unenriched data. Default is False.
+    return_all_events_for_log: Optional boolean to return all events generated from the ingested log.
+
+  Raises:
+    requests.exceptions.HTTPError: HTTP request resulted in an error
+      (response.status_code >= 400).
+
+  Requires the following IAM permission on the parent resource:
+  chronicle.events.batchGet
+  """
+  # pylint: enable=line-too-long
+  base_url_with_region = regions.url_always_prepend_region(
+      CHRONICLE_API_BASE_URL, proj_region)
+  # pylint: disable-next=line-too-long
+  instance = f"projects/{proj_id}/locations/{proj_region}/instances/{proj_instance}"
+  url = f"{base_url_with_region}/v1alpha/{instance}/legacy:legacyFindUdmEvents"
+
+  # Build query parameters
+  params = []
+  if tokens and not event_ids:  # event_ids take precedence over tokens
+    for token in tokens:
+      params.append(f"tokens={token}")
+  if event_ids:
+    for event_id in event_ids:
+      params.append(f"ids={event_id}")
+  if return_unenriched_data:
+    params.append("returnUnenrichedData=true")
+  if return_all_events_for_log:
+    params.append("returnAllEventsForLog=true")
+
+  if params:
+    url = f"{url}?{'&'.join(params)}"
+
+  response = http_session.request("GET", url)
+  if response.status_code >= 400:
+    print(response.text)
+  response.raise_for_status()
+
+  print(json.dumps(response.json(), indent=2))
+
+
+if __name__ == "__main__":
+  parser = argparse.ArgumentParser()
+  # common
+  chronicle_auth.add_argument_credentials_file(parser)
+  project_instance.add_argument_project_instance(parser)
+  project_id.add_argument_project_id(parser)
+  regions.add_argument_region(parser)
+  # local
+  parser.add_argument(
+      "--tokens",
+      type=str,
+      help=
+      'JSON string containing a list of tokens (e.g., \'["token1", "token2"]\')'
+  )
+  parser.add_argument(
+      "--event_ids",
+      type=str,
+      help=
+      'JSON string containing a list of event IDs (e.g., \'["id1", "id2"]\')')
+  parser.add_argument("--return_unenriched_data",
+                      action="store_true",
+                      help="Whether to return unenriched data")
+  parser.add_argument(
+      "--return_all_events_for_log",
+      action="store_true",
+      help="Whether to return all events generated from the ingested log")
+
+  args = parser.parse_args()
+
+  # Convert JSON strings to lists if provided
+  tokens_list = json.loads(args.tokens) if args.tokens else None
+  event_ids_list = json.loads(args.event_ids) if args.event_ids else None
+
+  # Validate that at least one of tokens or event_ids is provided
+  if not tokens_list and not event_ids_list:
+    parser.error("At least one of --tokens or --event_ids must be provided")
+
+  auth_session = chronicle_auth.initialize_http_session(
+      args.credentials_file,
+      SCOPES,
+  )
+  find_udm_events(auth_session, args.project_id, args.project_instance,
+                  args.region, tokens_list, event_ids_list,
+                  args.return_unenriched_data, args.return_all_events_for_log)
diff --git a/service_management/__init__.py b/service_management/__init__.py
index af1d471..3d87ee0 100644
--- a/service_management/__init__.py
+++ b/service_management/__init__.py
@@ -1,4 +1,4 @@
-# Copyright 2021 Google LLC
+# Copyright 2025 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..ec14755
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,48 @@
+#!/usr/bin/env python3
+
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Setup configuration for Chronicle API SDK."""
+
+from setuptools import find_packages
+from setuptools import setup
+
+setup(
+    name="chronicle-api",
+    version="0.1.3",
+    description="Chronicle API SDK and CLI",
+    author="Google LLC",
+    author_email="chronicle-support@google.com",
+    packages=find_packages(include=[
+        "common",
+        "sdk", "sdk.*",
+        "detect.v1alpha", "detect.v1alpha.*",
+        "ingestion.v1alpha", "ingestion.v1alpha.*",
+        "iocs.v1alpha", "iocs.v1alpha.*",
+        "lists.v1alpha", "lists.v1alpha.*",
+        "search.v1alpha", "search.v1alpha.*",
+    ]),
+    install_requires=[
+        "click>=8.0.0",
+        "google-auth>=2.0.0",
+        "requests>=2.25.0",
+        "python-dotenv>=1.0.0",
+    ],
+    entry_points={
+        "console_scripts": ["chronicle=sdk.cli:cli",],
+    },
+    exclude_package_data={"": [".gitignore"]},
+    python_requires=">=3.10",
+    license="Apache 2.0",
+)