Repository: phantomcyber/splunk-soar-sdk
Pull request #57: feat: base on_poll sdk implementation
Author: login: grokas-splunk
Created: 2025-05-20T16:47:08Z | Merged: 2025-05-27T19:58:55Z

[PAPP-36011](https://splunk.atlassian.net/browse/PAPP-36011)

- SDK on_poll implementation
- Handles yielding containers and artifacts
- Supports on_poll ingest parameters (start_time, end_time, etc...)
- Example usage updated in example app

Example usage:
![Screenshot 2025-05-20 at 3 23 17 PM](https://github.com/user-attachments/assets/04a61be0-935b-4210-8fdc-03c618c357ce)

![Screenshot 2025-05-20 at 9 21 39 AM](https://github.com/user-attachments/assets/8372a023-31c5-473a-9b07-4aff4c5ffa5d)



Diff:
diff --git a/docs/actions/action_anatomy.md b/docs/actions/action_anatomy.md
index b82e6508..e45adc68 100644
--- a/docs/actions/action_anatomy.md
+++ b/docs/actions/action_anatomy.md
@@ -120,7 +120,7 @@ Each action should return at least information of whether it has succeeded or fa
 The result should have the status on the success (`True` or `False`) and an extra verbose description on the status
 (which can be also an empty string).
 
-You can create more than one action restult for the action. At least one must be created for the action. The SDK
+You can create more than one action result for the action. At least one must be created for the action. The SDK
 takes care of that automatically by creating and reporting one from the returned tuple.
 
 [Read more on Action Results](./action_results.md)
diff --git a/pyproject.toml b/pyproject.toml
index 9199c442..65df56b4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -22,7 +22,8 @@ authors = [
     { name = "Janusz Kamieński", email = "jkamienski@splunk.com" },
     { name = "Michael Nordby", email = "mnordby@splunk.com" },
     { name = "Scott Odle", email = "sodle@splunk.com" },
-    { name = "Michał Posłuszny", email = "mposluszny@splunk.com" }
+    { name = "Michał Posłuszny", email = "mposluszny@splunk.com" },
+    { name = "Ian Rokas", email = "grokas@splunk.com" }
 ]
 classifiers = [
     "Development Status :: 4 - Beta",
diff --git a/src/soar_sdk/app.py b/src/soar_sdk/app.py
index 8b41b143..a30fde4c 100644
--- a/src/soar_sdk/app.py
+++ b/src/soar_sdk/app.py
@@ -3,6 +3,7 @@
 import sys
 from functools import wraps
 from typing import Any, Optional, Union, Callable
+from collections.abc import Iterator
 
 
 from soar_sdk.asset import BaseAsset
@@ -14,10 +15,13 @@
 from soar_sdk.app_cli_runner import AppCliRunner
 from soar_sdk.meta.actions import ActionMeta
 from soar_sdk.params import Params
+from soar_sdk.params import OnPollParams
 from soar_sdk.action_results import ActionOutput
+from soar_sdk.models.container import Container
+from soar_sdk.models.artifact import Artifact
 from soar_sdk.types import Action, action_protocol
 from soar_sdk.logging import getLogger
-from soar_sdk.exceptions import ActionFailure, AssetMisconfiguration
+from soar_sdk.exceptions import ActionFailure
 import traceback
 import uuid
 
@@ -180,7 +184,7 @@ def inner(
 
                 try:
                     result = function(action_params, *args, **kwargs)
-                except (ActionFailure, AssetMisconfiguration) as e:
+                except ActionFailure as e:
                     e.set_action_name(action_name)
                     return self._adapt_action_result(
                         ActionResult(status=False, message=str(e)), soar
@@ -260,7 +264,7 @@ def inner(
                         raise RuntimeError(
                             "Test connectivity function must not return any value (return type should be None)."
                         )
-                except (ActionFailure, AssetMisconfiguration) as e:
+                except ActionFailure as e:
                     e.set_action_name(action_name)
                     return self._adapt_action_result(
                         ActionResult(status=False, message=str(e)), soar
@@ -296,6 +300,173 @@ def inner(
 
         return test_con_function
 
+    def on_poll(self) -> Callable[[Callable], Action]:
+        """
+        Decorator for the on_poll action.
+
+        The decorated function must be a generator (using yield) or return an Iterator that yields Container and/or Artifact objects. Only one on_poll action is allowed per app.
+
+        Usage:
+        If a Container is yielded first, all subsequent Artifacts will be added to that container unless they already have a `container_id`.
+        If an `Artifact` is yielded without a container and no `container_id` is set, it will be skipped.
+        """
+
+        def on_poll_decorator(function: Callable) -> Action:
+            if self.actions_provider.get_action("on_poll"):
+                raise TypeError(
+                    "The 'on_poll' decorator can only be used once per App instance."
+                )
+
+            # Check if function is generator function or has a return type annotation of iterator
+            is_generator = inspect.isgeneratorfunction(function)
+            signature = inspect.signature(function)
+            has_iterator_return = False
+
+            # Check if the return annotation is an Iterator type
+            if (
+                signature.return_annotation != inspect.Signature.empty
+                and hasattr(signature.return_annotation, "__origin__")
+                and signature.return_annotation.__origin__ is Iterator
+            ):
+                has_iterator_return = True
+
+            if not (is_generator or has_iterator_return):
+                raise TypeError(
+                    "The on_poll function must be a generator (use 'yield') or return an Iterator."
+                )
+
+            action_identifier = "on_poll"
+            action_name = "on poll"
+
+            # Use OnPollParams for on_poll actions
+            validated_params_class = OnPollParams
+            logger = self.__logger
+
+            @action_protocol
+            @wraps(function)
+            def inner(
+                params: OnPollParams,
+                client: SOARClient = self.actions_provider.soar_client,
+                *args: Any,  # noqa: ANN401
+                **kwargs: Any,  # noqa: ANN401
+            ) -> bool:
+                try:
+                    # Validate poll params
+                    try:
+                        action_params = validated_params_class.parse_obj(params)
+                    except Exception as e:
+                        logger.info(f"Parameter validation error: {e!s}")
+                        return self._adapt_action_result(
+                            ActionResult(
+                                status=False, message=f"Invalid parameters: {e!s}"
+                            ),
+                            client,
+                        )
+
+                    kwargs = self._build_magic_args(function, client=client, **kwargs)
+
+                    result = function(action_params, *args, **kwargs)
+
+                    # Check if container_id is provided in params
+                    container_id = getattr(params, "container_id", None)
+                    container_created = False
+
+                    for item in result:
+                        # Check if the item is a Container
+                        if isinstance(item, Container):
+                            # TODO: Change save_container for incorporation with container.create()
+                            container = item.to_dict()  # Convert for saving
+                            ret_val, message, cid = client.save_container(container)  # type: ignore[attr-defined]
+                            logger.info(f"Creating container: {container['name']}")
+
+                            if ret_val:
+                                container_id = cid
+                                container_created = True
+                                item.container_id = container_id
+
+                            # Covered by test_on_poll::test_on_poll_yields_container_duplicate, but branch coverage detection on generator functions is wonky
+                            if (
+                                "duplicate container found" in message.lower()
+                            ):  # pragma: no cover
+                                logger.info(
+                                    "Duplicate container found, reusing existing container"
+                                )
+
+                            continue
+
+                        # Check for Artifact
+                        if not isinstance(item, Artifact):
+                            logger.info(
+                                f"Warning: Item is not a Container or Artifact, skipping: {item}"
+                            )
+                            continue
+
+                        artifact_dict = item.to_dict()  # Convert for saving
+
+                        if (
+                            not container_id
+                            and not container_created
+                            and "container_id" not in artifact_dict
+                        ):
+                            # No container for this artifact
+                            logger.info(
+                                f"Warning: Artifact has no container, skipping: {item}"
+                            )
+                            continue
+
+                        if container_id and "container_id" not in artifact_dict:
+                            # Set the container_id
+                            artifact_dict["container_id"] = container_id
+                            item.container_id = container_id
+
+                        # TODO: Change save_artifact for incorporation with artifact.create()
+                        client.save_artifacts([artifact_dict])  # type: ignore[attr-defined]
+                        logger.info(
+                            f"Added artifact: {artifact_dict.get('name', 'Unnamed artifact')}"
+                        )
+
+                    return self._adapt_action_result(
+                        ActionResult(status=True, message="Polling complete"), client
+                    )
+                except ActionFailure as e:
+                    e.set_action_name(action_name)
+                    return self._adapt_action_result(
+                        ActionResult(status=False, message=str(e)), client
+                    )
+                except Exception as e:
+                    client.add_exception(e)
+                    logger.info(f"Error during polling: {e!s}")
+                    return self._adapt_action_result(
+                        ActionResult(status=False, message=str(e)), client
+                    )
+
+            inner.params_class = validated_params_class
+
+            # Custom ActionMeta class for on_poll (has no output)
+            class OnPollActionMeta(ActionMeta):
+                def dict(self, *args: object, **kwargs: object) -> dict[str, Any]:
+                    data = super().dict(*args, **kwargs)
+                    # Poll actions have no output
+                    data["output"] = []
+                    return data
+
+            inner.meta = OnPollActionMeta(
+                action=action_name,
+                identifier=action_identifier,
+                description=inspect.getdoc(function) or action_name,
+                verbose="Callback action for the on_poll ingest functionality",
+                type="ingest",
+                read_only=True,
+                parameters=validated_params_class,
+                versions="EQ(*)",
+            )
+
+            self.actions_provider.set_action(action_identifier, inner)
+            self._dev_skip_in_pytest(function, inner)
+            return inner
+
+        return on_poll_decorator
+
     @staticmethod
     def _validate_params_class(
         action_name: str,
diff --git a/src/soar_sdk/input_spec.py b/src/soar_sdk/input_spec.py
index 839683b7..5877ba4a 100644
--- a/src/soar_sdk/input_spec.py
+++ b/src/soar_sdk/input_spec.py
@@ -58,7 +58,9 @@ class ActionParameter(BaseModel):
     'context' field containing metadata.
     """
 
-    context: Optional[ParameterContext] = None
+    # Removed to prevent from appearing in parameters unless explicitly provided as an extra field
+    # Was causing issues with the platform during check on context (which was None from here)
+    # context: Optional[ParameterContext] = None # noqa: ERA001
 
     # Additional keys are action-specific and not predictable here.
     class Config:
diff --git a/src/soar_sdk/models/__init__.py b/src/soar_sdk/models/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/soar_sdk/models/artifact.py b/src/soar_sdk/models/artifact.py
new file mode 100644
index 00000000..9c7eb646
--- /dev/null
+++ b/src/soar_sdk/models/artifact.py
@@ -0,0 +1,37 @@
+from typing import Optional, Any, Union
+from pydantic import BaseModel
+
+
+class Artifact(BaseModel):
+    """
+    Represents an artifact to be created during on_poll.
+
+    This class allows users to create and configure artifacts when yielding from an on_poll function.
+    """
+
+    class Config:
+        extra = "forbid"
+
+    name: Optional[str] = None
+    label: Optional[str] = None
+    description: Optional[str] = None
+    type: Optional[str] = None
+    severity: Optional[str] = None
+    source_data_identifier: Optional[str] = None
+    container_id: Optional[int] = None
+    data: Optional[dict[str, Any]] = None
+    run_automation: bool = False
+    owner_id: Optional[Union[int, str]] = None
+    cef: Optional[dict[str, Any]] = None
+    cef_types: Optional[dict[str, list[str]]] = None
+    ingest_app_id: Optional[Union[int, str]] = None
+    tags: Optional[Union[list[str], str]] = None
+    start_time: Optional[str] = None
+    end_time: Optional[str] = None
+    kill_chain: Optional[str] = None
+
+    def to_dict(self) -> dict[str, Any]:
+        """
+        Convert the artifact to a dictionary (needed for save_artifact).
+        """
+        return self.dict(exclude_none=True)
diff --git a/src/soar_sdk/models/container.py b/src/soar_sdk/models/container.py
new file mode 100644
index 00000000..6cebf540
--- /dev/null
+++ b/src/soar_sdk/models/container.py
@@ -0,0 +1,48 @@
+from pydantic import BaseModel
+from typing import Optional, Any, Union
+
+
+class Container(BaseModel):
+    """
+    Represents a container to be created during on_poll.
+
+    This class allows users to specify container properties when yielding from an on_poll function.
+    """
+
+    class Config:
+        extra = "forbid"
+
+    name: str
+    label: Optional[str] = None
+    description: Optional[str] = None
+    source_data_identifier: Optional[str] = None
+    severity: Optional[str] = None
+    status: Optional[str] = None
+    tags: Optional[Union[list[str], str]] = None
+    owner_id: Optional[Union[int, str]] = None
+    sensitivity: Optional[str] = None
+    artifacts: Optional[list[dict[str, Any]]] = None
+    asset_id: Optional[int] = None
+    close_time: Optional[str] = None
+    custom_fields: Optional[dict[str, Any]] = None
+    data: Optional[dict[str, Any]] = None
+    due_time: Optional[str] = None
+    end_time: Optional[str] = None
+    ingest_app_id: Optional[int] = None
+    kill_chain: Optional[str] = None
+    role_id: Optional[Union[int, str]] = None
+    run_automation: bool = False
+    start_time: Optional[str] = None
+    open_time: Optional[str] = None
+    tenant_id: Optional[Union[int, str]] = None
+    container_type: Optional[str] = None
+    template_id: Optional[int] = None
+    authorized_users: Optional[list[int]] = None
+    artifact_count: Optional[int] = None
+    container_id: Optional[str] = None
+
+    def to_dict(self) -> dict[str, Any]:
+        """
+        Convert the container to a dictionary (needed for save_container).
+        """
+        return self.dict(exclude_none=True)
diff --git a/src/soar_sdk/params.py b/src/soar_sdk/params.py
index f08bd778..6d025e8a 100644
--- a/src/soar_sdk/params.py
+++ b/src/soar_sdk/params.py
@@ -132,3 +132,37 @@ def _to_json_schema(cls) -> dict[str, InputFieldSpecification]:
             params[field_name] = params_field
 
         return params
+
+
+class OnPollParams(Params):
+    """
+    Parameters for on_poll.
+
+    Used to define the specific parameters for the on_poll action.
+    """
+
+    start_time: int = Param(
+        description="Start of time range, in epoch time (milliseconds).",
+        required=False,
+    )
+
+    end_time: int = Param(
+        description="End of time range, in epoch time (milliseconds).",
+        required=False,
+    )
+
+    container_count: int = Param(
+        description="Maximum number of container records to query for.",
+        required=False,
+    )
+
+    artifact_count: int = Param(
+        description="Maximum number of artifact records to query for.",
+        required=False,
+    )
+
+    container_id: str = Param(
+        description="Comma-separated list of container IDs to limit the ingestion to.",
+        required=False,
+        allow_list=True,
+    )
diff --git a/tests/example_app/app.json b/tests/example_app/app.json
index 2efbbc2d..426bbe62 100644
--- a/tests/example_app/app.json
+++ b/tests/example_app/app.json
@@ -12,30 +12,33 @@
     "logo": "logo.svg",
     "logo_dark": "logo_dark.svg",
     "product_name": "Example App",
-    "python_version": ["3.9", "3.13"],
+    "python_version": [
+        "3.9",
+        "3.13"
+    ],
     "product_version_regex": ".*",
     "publisher": "Splunk Inc.",
     "utctime_updated": "2025-04-17T12:00:00.000000Z",
     "fips_compliant": false,
     "configuration": {
         "base_url": {
-            "order": 0,
             "data_type": "string",
+            "required": true,
             "description": "Base Url",
-            "required": true
+            "order": 0
         },
         "api_key": {
-            "order": 1,
             "data_type": "password",
+            "required": true,
             "description": "API key for authentication",
-            "required": true
+            "order": 1
         },
         "key_header": {
             "data_type": "string",
-            "default": "Authorization",
+            "required": true,
             "description": "Header for API key authentication",
             "order": 2,
-            "required": true,
+            "default": "Authorization",
             "value_list": [
                 "Authorization",
                 "X-API-Key"
@@ -108,6 +111,63 @@
                     "data_type": "string"
                 }
             ]
+        },
+        {
+            "action": "on poll",
+            "identifier": "on_poll",
+            "description": "on poll",
+            "verbose": "Callback action for the on_poll ingest functionality",
+            "type": "ingest",
+            "read_only": true,
+            "versions": "EQ(*)",
+            "parameters": {
+                "start_time": {
+                    "order": 0,
+                    "name": "start_time",
+                    "description": "Start of time range, in epoch time (milliseconds).",
+                    "data_type": "numeric",
+                    "required": false,
+                    "primary": false,
+                    "allow_list": false
+                },
+                "end_time": {
+                    "order": 1,
+                    "name": "end_time",
+                    "description": "End of time range, in epoch time (milliseconds).",
+                    "data_type": "numeric",
+                    "required": false,
+                    "primary": false,
+                    "allow_list": false
+                },
+                "container_count": {
+                    "order": 2,
+                    "name": "container_count",
+                    "description": "Maximum number of container records to query for.",
+                    "data_type": "numeric",
+                    "required": false,
+                    "primary": false,
+                    "allow_list": false
+                },
+                "artifact_count": {
+                    "order": 3,
+                    "name": "artifact_count",
+                    "description": "Maximum number of artifact records to query for.",
+                    "data_type": "numeric",
+                    "required": false,
+                    "primary": false,
+                    "allow_list": false
+                },
+                "container_id": {
+                    "order": 4,
+                    "name": "container_id",
+                    "description": "Comma-separated list of container IDs to limit the ingestion to.",
+                    "data_type": "string",
+                    "required": false,
+                    "primary": false,
+                    "allow_list": true
+                }
+            },
+            "output": []
         }
     ],
     "pip39_dependencies": {
diff --git a/tests/example_app/src/app.py b/tests/example_app/src/app.py
index bd4d6e76..b15b3ef9 100644
--- a/tests/example_app/src/app.py
+++ b/tests/example_app/src/app.py
@@ -1,9 +1,14 @@
 #!/usr/bin/env python
+from typing import Union
+from collections.abc import Iterator
+from datetime import datetime, timezone
 from soar_sdk.abstract import SOARClient
 from soar_sdk.app import App
 from soar_sdk.asset import AssetField, BaseAsset
-from soar_sdk.params import Params
+from soar_sdk.params import Params, OnPollParams
 from soar_sdk.action_results import ActionOutput
+from soar_sdk.models.container import Container
+from soar_sdk.models.artifact import Artifact
 from soar_sdk.logging import getLogger
 
 logger = getLogger()
@@ -54,5 +59,39 @@ def reverse_string(param: ReverseStringParams, soar: SOARClient) -> ReverseStrin
     return ReverseStringOutput(reversed_string=reversed_string)
 
 
+@app.on_poll()
+def on_poll(
+    params: OnPollParams, client: SOARClient, asset: Asset
+) -> Iterator[Union[Container, Artifact]]:
+    # Create container first for artifacts
+    yield Container(
+        name="Network Alerts",
+        description="Some network-related alerts",
+        severity="medium",
+    )
+
+    # Simulate collecting 2 network artifacts that will be put in the network alerts container
+    for i in range(1, 3):
+        logger.info(f"Processing network artifact {i}")
+
+        alert_id = f"testalert-{datetime.now(timezone.utc).strftime('%Y%m%d')}-{i}"
+        artifact = Artifact(
+            name=f"Network Alert {i}",
+            label="alert",
+            severity="medium",
+            source_data_identifier=alert_id,
+            type="network",
+            description=f"Example network alert {i} from polling operation",
+            data={
+                "alert_id": alert_id,
+                "source_ip": f"10.0.0.{i}",
+                "destination_ip": "192.168.0.1",
+                "protocol": "TCP",
+            },
+        )
+
+        yield artifact
+
+
 if __name__ == "__main__":
     app.cli()
diff --git a/tests/example_app/uv.lock b/tests/example_app/uv.lock
index 201d26f2..08ab3f73 100644
--- a/tests/example_app/uv.lock
+++ b/tests/example_app/uv.lock
@@ -962,7 +962,7 @@ wheels = [
 
 [[package]]
 name = "splunk-soar-sdk"
-version = "1.0.0b19"
+version = "1.0.0b21"
 source = { directory = "../../" }
 dependencies = [
     { name = "beautifulsoup4", marker = "(python_full_version < '3.10' and platform_machine == 'arm64' and sys_platform == 'darwin') or (python_full_version >= '3.13' and platform_machine == 'arm64' and sys_platform == 'darwin') or (python_full_version < '3.10' and platform_machine == 'x86_64' and sys_platform == 'darwin') or (python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'darwin') or (python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.10' and platform_machine == 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux')" },
diff --git a/tests/test_container.py b/tests/test_container.py
new file mode 100644
index 00000000..45bf90b9
--- /dev/null
+++ b/tests/test_container.py
@@ -0,0 +1,155 @@
+from soar_sdk.models.container import Container
+from soar_sdk.models.artifact import Artifact
+
+
+def test_container_basic():
+    """Test basic Container functionality."""
+    container = Container(
+        name="Test Container",
+        label="alert",
+        description="Test Description",
+        source_data_identifier="test_1234",
+        severity="medium",
+    )
+
+    container_dict = container.to_dict()
+    assert container_dict["name"] == "Test Container"
+    assert container_dict["label"] == "alert"
+    assert container_dict["description"] == "Test Description"
+    assert container_dict["source_data_identifier"] == "test_1234"
+    assert container_dict["severity"] == "medium"
+
+
+def test_container_invalid_attribute():
+    """Test setting an invalid attribute should raise ValidationError."""
+    from pydantic import ValidationError
+    import pytest
+
+    try:
+        Container(name="Test Container", not_allowed="fail")
+        pytest.fail("Setting an invalid attribute should raise ValidationError")
+    except ValidationError:
+        pass
+
+
+def test_container_missing_required_field():
+    """Test that missing required fields raises ValidationError."""
+    from pydantic import ValidationError
+    import pytest
+
+    try:
+        Container()
+        pytest.fail("Missing required field 'name' should raise ValidationError")
+    except ValidationError:
+        pass
+
+
+def test_container_type_validation():
+    """Test that invalid types raise ValidationError."""
+    from pydantic import ValidationError
+    import pytest
+
+    try:
+        Container(name={"invalid": "type"})
+        pytest.fail("Invalid type for 'name' should raise ValidationError")
+    except ValidationError:
+        pass
+
+
+def test_container_default_values():
+    """Test that default values are set correctly."""
+    container = Container(name="Test Container")
+    assert container.run_automation is False
+    assert container.to_dict()["run_automation"] is False
+
+
+def test_container_serialization_deserialization():
+    """Test serialization and deserialization."""
+    data = {
+        "name": "Test Container",
+        "label": "alert",
+        "severity": "medium",
+    }
+    container = Container(**data)
+    container_dict = container.to_dict()
+    new_container = Container(**container_dict)
+    assert new_container == container
+
+
+def test_artifact_basic():
+    """Test basic Artifact functionality."""
+    artifact = Artifact(
+        name="Test Artifact",
+        label="alert",
+        description="Test Description",
+        type="network",
+        severity="medium",
+    )
+
+    artifact_dict = artifact.to_dict()
+    assert artifact_dict["name"] == "Test Artifact"
+    assert artifact_dict["label"] == "alert"
+    assert artifact_dict["description"] == "Test Description"
+    assert artifact_dict["type"] == "network"
+    assert artifact_dict["severity"] == "medium"
+
+
+def test_artifact_with_data():
+    """Test Artifact with data property."""
+    artifact = Artifact(
+        name="Custom Artifact",
+        label="event",
+        type="host",
+        data={"ip": "192.168.0.1", "hostname": "test.local"},
+    )
+
+    artifact_dict = artifact.to_dict()
+    assert artifact_dict["name"] == "Custom Artifact"
+    assert artifact_dict["label"] == "event"
+    assert artifact_dict["type"] == "host"
+    assert artifact_dict["data"]["ip"] == "192.168.0.1"
+    assert artifact.data["hostname"] == "test.local"
+
+
+def test_artifact_invalid_attribute():
+    """Test setting an invalid attribute should raise ValidationError."""
+    from pydantic import ValidationError
+    import pytest
+
+    try:
+        Artifact(name="Test Artifact", not_allowed="fail")
+        pytest.fail("Setting an invalid attribute should raise ValidationError")
+    except ValidationError:
+        pass
+
+
+def test_artifact_type_validation():
+    """Test that invalid types raise ValidationError for Artifact."""
+    from pydantic import ValidationError
+    import pytest
+
+    try:
+        Artifact(name={"invalid": "type"})
+        pytest.fail("Invalid type for 'name' should raise ValidationError")
+    except ValidationError:
+        pass
+
+
+def test_artifact_default_values():
+    """Test that default values are set correctly for Artifact."""
+    artifact = Artifact()
+    assert artifact.run_automation is False
+    assert artifact.to_dict()["run_automation"] is False
+
+
+def test_artifact_serialization_deserialization():
+    """Test serialization and deserialization."""
+    data = {
+        "name": "Test Artifact",
+        "label": "alert",
+        "severity": "medium",
+    }
+    artifact = Artifact(**data)
+    artifact_dict = artifact.to_dict()
+    new_artifact = Artifact(**artifact_dict)
+    assert new_artifact == artifact
diff --git a/tests/test_on_poll.py b/tests/test_on_poll.py
new file mode 100644
index 00000000..b3902ba8
--- /dev/null
+++ b/tests/test_on_poll.py
@@ -0,0 +1,297 @@
+from collections.abc import Iterator
+import pytest
+from unittest import mock
+
+from soar_sdk.app import App
+from soar_sdk.params import OnPollParams
+from soar_sdk.models.container import Container
+from soar_sdk.models.artifact import Artifact
+from soar_sdk.exceptions import ActionFailure
+
+
+def test_on_poll_decoration_fails_when_used_more_than_once(simple_app: App):
+    """Test that the on_poll decorator can only be used once per app."""
+
+    @simple_app.on_poll()
+    def on_poll_function(params: OnPollParams, client=None) -> Iterator[dict]:
+        yield {"data": "test"}
+
+    with pytest.raises(TypeError, match=r"on_poll.+once per"):
+
+        @simple_app.on_poll()
+        def second_on_poll(params: OnPollParams, client=None) -> Iterator[dict]:
+            yield {"data": "another test"}
+
+
+def test_on_poll_decoration_fails_when_not_generator(simple_app: App):
+    """Test that the on_poll decorator requires a generator function."""
+
+    with pytest.raises(
+        TypeError,
+        match=r"The on_poll function must be a generator \(use 'yield'\) or return an Iterator.",
+    ):
+
+        @simple_app.on_poll()
+        def on_poll_function(params: OnPollParams, client=None):
+            return {"data": "test"}  # Not yielding
+
+
+def test_on_poll_function_called_with_params(simple_app: App):
+    """Test that the on_poll function is called with parameters."""
+    poll_fn = mock.Mock(return_value=iter([{"data": "test"}]))
+
+    @simple_app.on_poll()
+    def on_poll_function(params: OnPollParams, client=None) -> Iterator[dict]:
+        poll_fn(params)
+        yield {"data": "test"}
+
+    client_mock = mock.Mock()
+    params = OnPollParams(
+        start_time=0,
+        end_time=1,
+        container_count=10,
+        artifact_count=100,
+        container_id=1,
+    )
+
+    result = on_poll_function(params, client=client_mock)
+
+    poll_fn.assert_called_once_with(params)
+    assert result is True
+
+
+def test_on_poll_param_validation_error(simple_app: App):
+    """Test on_poll handles parameter validation errors and returns False."""
+    client_mock = mock.Mock()
+
+    @simple_app.on_poll()
+    def on_poll_function(params: OnPollParams):
+        yield Artifact(name="a1")
+
+    invalid_params = "invalid"
+    result = on_poll_function(invalid_params, client=client_mock)
+    assert result is False
+
+
+def test_on_poll_works_with_iterator_functions(simple_app: App):
+    """Test that the on_poll decorator works with functions that return iterators."""
+
+    @simple_app.on_poll()
+    def on_poll_function(params: OnPollParams, client=None) -> Iterator[dict]:
+        # Creating and returning an iterator
+        return iter([{"data": "test artifact 1"}, {"data": "test artifact 2"}])
+
+    client_mock = mock.Mock()
+    params = OnPollParams(
+        start_time=0,
+        end_time=1,
+        container_count=10,
+        artifact_count=100,
+    )
+
+    result = on_poll_function(params, client=client_mock)
+
+    assert result is True
+
+
+def test_on_poll_empty_iterator(simple_app: App):
+    """Test that the on_poll function works with empty iterators."""
+
+    @simple_app.on_poll()
+    def on_poll_function(params: OnPollParams, client=None) -> Iterator[dict]:
+        # Empty iterator - no artifacts to yield
+        return iter([])
+
+    client_mock = mock.Mock()
+    params = OnPollParams(
+        start_time=0,
+        end_time=1,
+        container_id=1,
+    )
+
+    result = on_poll_function(params, client=client_mock)
+
+    assert result is True
+
+
+def test_on_poll_raises_exception_propagates(simple_app: App):
+    """Test that exceptions raised in the on_poll function are handled and return False."""
+
+    @simple_app.on_poll()
+    def on_poll_function(params: OnPollParams, client=None) -> Iterator[dict]:
+        raise ValueError("poll error")
+        yield  # pragma: no cover
+
+    client_mock = mock.Mock()
+    params = OnPollParams(
+        start_time=0,
+        end_time=1,
+        container_count=10,
+        artifact_count=100,
+        container_id=1,
+    )
+
+    result = on_poll_function(params, client=client_mock)
+    assert result is False
+
+
+def test_on_poll_multiple_yields(simple_app: App):
+    """Test that multiple yielded items are processed by on_poll."""
+    yielded = []
+
+    @simple_app.on_poll()
+    def on_poll_function(params: OnPollParams, client=None) -> Iterator[dict]:
+        for i in range(3):
+            yielded.append(i)
+            yield {"data": i}
+
+    client_mock = mock.Mock()
+    params = OnPollParams(
+        start_time=0,
+        end_time=1,
+        container_count=10,
+        artifact_count=100,
+        container_id=1,
+    )
+
+    result = on_poll_function(params, client=client_mock)
+    assert result is True
+    assert yielded == [0, 1, 2]
+
+
+def test_on_poll_yields_container_success(simple_app: App):
+    """Test on_poll yields a Container and successfully."""
+    client_mock = mock.Mock()
+    client_mock.save_container.return_value = (True, "Created", 42)
+    client_mock.save_artifacts.return_value = None
+
+    @simple_app.on_poll()
+    def on_poll_function(params: OnPollParams, client=None):
+        yield Container(name="c1")
+        yield Artifact(name="a1")
+
+    params = OnPollParams(start_time=0, end_time=1)
+    result = on_poll_function(params, client=client_mock)
+    assert result is True
+    assert client_mock.save_container.call_count == 1
+    client_mock.save_artifacts.assert_called()
+
+
+def test_on_poll_yields_container_duplicate(simple_app: App):
+    """Test on_poll yields a Container and handles duplicate container correctly."""
+    client_mock = mock.Mock()
+    client_mock.save_container.return_value = (True, "Duplicate container found", 99)
+    client_mock.save_artifacts.return_value = None
+
+    @simple_app.on_poll()
+    def on_poll_function_dup(params: OnPollParams, client=None):
+        yield Container(name="c2")
+        yield Artifact(name="a2")
+
+    params = OnPollParams(start_time=0, end_time=1)
+    result = on_poll_function_dup(params, client=client_mock)
+    assert result is True
+    assert client_mock.save_container.call_count == 1
+    client_mock.save_artifacts.assert_called()
+
+
+def test_on_poll_yields_container_creation_failure(simple_app: App):
+    """Test on_poll handles container creation failure correctly."""
+    client_mock = mock.Mock()
+    client_mock.save_container.return_value = (False, "Error creating container", None)
+    client_mock.save_artifacts.return_value = None
+
+    @simple_app.on_poll()
+    def on_poll_function(params: OnPollParams, client=None):
+        yield Container(name="c3")
+        yield Artifact(name="a3")  # Should be skipped because no container
+
+    params = OnPollParams(start_time=0, end_time=1)
+    result = on_poll_function(params, client=client_mock)
+    assert result is True
+    assert client_mock.save_container.call_count == 1
+    client_mock.save_artifacts.assert_not_called()
+
+
+def test_on_poll_yields_non_container_artifact(simple_app: App):
+    """Test on_poll correctly handles object that is neither Container nor Artifact."""
+    client_mock = mock.Mock()
+
+    @simple_app.on_poll()
+    def on_poll_function(params: OnPollParams, client=None):
+        yield 123  # Should be skipped
+        yield "string"
+
+    params = OnPollParams(start_time=0, end_time=1)
+    result = on_poll_function(params, client=client_mock)
+    assert result is True
+
+
+def test_on_poll_artifact_no_container(simple_app: App):
+    """Test on_poll yields an Artifact with no container and no container_id."""
+    client_mock = mock.Mock()
+
+    @simple_app.on_poll()
+    def on_poll_function(params: OnPollParams, client=None):
+        yield Artifact(name="a1")
+
+    params = OnPollParams(start_time=0, end_time=1)
+    result = on_poll_function(params, client=client_mock)
+    assert result is True
+
+
+def test_on_poll_sets_container_id_on_artifact(simple_app: App):
+    """Test that on_poll sets container_id on artifact if not present and container_id is available."""
+    client_mock = mock.Mock()
+
+    @simple_app.on_poll()
+    def on_poll_function(params: OnPollParams, client=None):
+        artifact2 = Artifact(name="a2", container_id=999)
+        yield artifact2
+
+    params = OnPollParams(start_time=0, end_time=1)
+    on_poll_function(params, client=client_mock)
+    called_artifacts = client_mock.save_artifacts.call_args_list
+    saved_artifact = called_artifacts[0][0][0][0]
+    assert saved_artifact["container_id"] == 999
+
+
+def test_on_poll_failure(simple_app: App):
+    """Test on_poll handles ActionFailure correctly."""
+    client_mock = mock.Mock()
+
+    # ActionFailure
+    @simple_app.on_poll()
+    def on_poll_actionfailure(params: OnPollParams, client=None):
+        raise ActionFailure("failmsg")
+        yield  # pragma: no cover
+
+    params = OnPollParams(start_time=0, end_time=1)
+    result = on_poll_actionfailure(params, client=client_mock)
+    assert result is False
+
+
+def test_on_poll_decoration_with_meta(simple_app: App):
+    """Test that the on_poll decorator properly sets up metadata."""
+
+    @simple_app.on_poll()
+    def on_poll_function(params: OnPollParams, client=None) -> Iterator[dict]:
+        yield {"data": "test"}
+
+    action = simple_app.actions_provider.get_action("on_poll")
+    assert action is not None
+    assert action.meta.action == "on poll"
+    assert action == on_poll_function
+
+
+def test_on_poll_actionmeta_dict_output_empty(simple_app: App):
+    """Test that OnPollActionMeta.dict returns output as an empty list."""
+
+    @simple_app.on_poll()
+    def on_poll_function(params: OnPollParams, client=None):
+        yield Artifact(name="a1")
+
+    action = simple_app.actions_provider.get_action("on_poll")
+    meta_dict = action.meta.dict()
+    assert "output" in meta_dict
+    assert meta_dict["output"] == []


Reviews:
user: login: phantom-jacob
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-20T17:51:03Z
user: login: tapishj-splunk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T18:46:22Z
user: login: tapishj-splunk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T18:51:20Z
user: login: grokas-splunk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T23:24:35Z
user: login: grokas-splunk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T23:24:57Z
user: login: grokas-splunk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T23:29:00Z
user: login: grokas-splunk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T23:29:15Z
user: login: grokas-splunk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T23:29:32Z
user: login: grokas-splunk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T23:29:45Z
user: login: grokas-splunk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T23:29:55Z
user: login: grokas-splunk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T23:31:03Z
user: login: grokas-splunk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T23:31:16Z
user: login: grokas-splunk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T23:31:37Z
user: login: grokas-splunk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T23:32:02Z
user: login: grokas-splunk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T23:36:22Z
user: login: tapishj-splunk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T22:58:10Z
user: login: grokas-splunk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T00:35:34Z
user: login: phantom-jacob
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-23T06:46:08Z
user: login: phantom-jacob
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-23T06:46:34Z
user: login: phantom-jacob
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-23T07:03:32Z
user: login: grokas-splunk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T17:15:25Z
user: login: grokas-splunk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T17:24:03Z
user: login: grokas-splunk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T17:27:20Z
user: login: grokas-splunk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T17:51:59Z
user: login: grokas-splunk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T17:55:01Z
user: login: grokas-splunk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T17:55:12Z
user: login: grokas-splunk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T17:55:56Z
user: login: grokas-splunk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T20:48:51Z
user: login: phantom-jacob
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-27T18:04:02Z
user: login: phantom-jacob
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-27T18:11:01Z
user: login: grokas-splunk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-27T18:23:31Z
user: login: grokas-splunk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-27T19:55:51Z
user: login: phantom-jacob
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-27T19:57:55Z
user: login: phantom-jacob
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-27T19:58:34Z

Review comments:
diff_hunk: @@ -0,0 +1,89 @@
+#!/usr/bin/env python
+from typing import Optional, Any, Union
+
+
+class Artifact:
path: src/soar_sdk/artifact.py
user: login: phantom-jacob
body: Why not make this a pydantic model? That would cover a lot of the boilerplate for you
created_at: 2025-05-20T17:38:48Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,119 @@
+#!/usr/bin/env python
+from typing import Optional, Any, Union
+
+
+class Container:
path: src/soar_sdk/container.py
user: login: phantom-jacob
body: Same here, consider either a pydantic model or a dataclass to avoid all the extra boilerplate code
created_at: 2025-05-20T17:39:27Z
author_association: COLLABORATOR
diff_hunk: @@ -132,3 +132,37 @@ def _to_json_schema(cls) -> dict[str, InputFieldSpecification]:
             params[field_name] = params_field
 
         return params
+
+
+class OnPollParams(Params):
+    """
+    Parameters for on_poll.
+
+    Used to define the specific parameters for the on_poll action.
+    """
+
+    start_time: int = Param(
+        description="Start of time range, in epoch time (milliseconds).",
+        required=False,
+    )
+
+    end_time: int = Param(
+        description="End of time range, in epoch time (milliseconds).",
+        required=False,
+    )
+
+    container_count: int = Param(
+        description="Maximum number of container records to query for.",
+        required=False,
+    )
+
+    artifact_count: int = Param(
+        description="Maximum number of artifact records to query for.",
+        required=False,
+    )
+
+    container_id: str = Param(
+        description="Container IDs to limit the ingestion to.",
+        required=False,
+        allow_list=True,
+    )
path: src/soar_sdk/params.py
user: login: phantom-jacob
body: Is this supposed to be a comma-separated list of container IDs?
created_at: 2025-05-20T17:40:21Z
author_association: COLLABORATOR
diff_hunk: @@ -56,5 +61,38 @@ def reverse_string(
     return ReverseStringOutput(reversed_string=reversed_string)
 
 
+@app.on_poll()
+def on_poll(
+    params: OnPollParams, client: SOARClient, asset: Asset
+) -> Iterator[Union[Container, Artifact]]:
+    # Create container first for artifacts
+    yield Container(
+        name="Network Alerts",
+        description="Some network-related alerts",
+        severity="medium",
+    )
+
+    # Simulate collecting 2 network artifacts that will be put in the network alerts container
+    for i in range(2):
+        client.save_progress(f"Processing network artifact {i}")
path: tests/example_app/src/app.py
user: login: phantom-jacob
body: Prefer using the logger here
```suggestion
        logger.info(f"Processing network artifact {i}")
```
created_at: 2025-05-20T17:42:02Z
author_association: COLLABORATOR
diff_hunk: @@ -56,5 +61,38 @@ def reverse_string(
     return ReverseStringOutput(reversed_string=reversed_string)
 
 
+@app.on_poll()
+def on_poll(
+    params: OnPollParams, client: SOARClient, asset: Asset
+) -> Iterator[Union[Container, Artifact]]:
+    # Create container first for artifacts
+    yield Container(
+        name="Network Alerts",
+        description="Some network-related alerts",
+        severity="medium",
+    )
+
+    # Simulate collecting 2 network artifacts that will be put in the network alerts container
+    for i in range(2):
+        client.save_progress(f"Processing network artifact {i}")
+
+        artifact = Artifact(
+            name=f"Network Alert {i + 1}",
+            label="alert",
+            severity="medium",
+            source_data_identifier=asset.base_url,
+            type="network",
+            description=f"Example network alert {i + 1} from polling operation",
+            data={
+                "alert_id": f"testalert-{datetime.now(timezone.utc).strftime('%Y%m%d')}-{i + 1}",
+                "source_ip": f"10.0.0.{i + 1}",
+                "destination_ip": "192.168.0.1",
+                "protocol": "TCP",
+            },
+        )
path: tests/example_app/src/app.py
user: login: phantom-jacob
body: I know this is a contrived example, but it would be more realistic for the SDI to be the alert ID:
```suggestion
        alert_id = f"testalert-{datetime.now(timezone.utc).strftime('%Y%m%d')}-{i + 1}"
        artifact = Artifact(
            name=f"Network Alert {i + 1}",
            label="alert",
            severity="medium",
            source_data_identifier=alert_id,
            type="network",
            description=f"Example network alert {i + 1} from polling operation",
            data={
                "alert_id": alert_id,
                "source_ip": f"10.0.0.{i + 1}",
                "destination_ip": "192.168.0.1",
                "protocol": "TCP",
            },
        )
```
created_at: 2025-05-20T17:43:24Z
author_association: COLLABORATOR
diff_hunk: @@ -56,5 +61,38 @@ def reverse_string(
     return ReverseStringOutput(reversed_string=reversed_string)
 
 
+@app.on_poll()
+def on_poll(
+    params: OnPollParams, client: SOARClient, asset: Asset
+) -> Iterator[Union[Container, Artifact]]:
+    # Create container first for artifacts
+    yield Container(
+        name="Network Alerts",
+        description="Some network-related alerts",
+        severity="medium",
+    )
+
+    # Simulate collecting 2 network artifacts that will be put in the network alerts container
+    for i in range(2):
path: tests/example_app/src/app.py
user: login: phantom-jacob
body: You could avoid the `i + 1` math by changing the range here
```suggestion
    for i in range(1, 3):
```
created_at: 2025-05-20T17:44:00Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,152 @@
+#!/usr/bin/python
+from collections.abc import Iterator
+import pytest
+from unittest import mock
+
+from soar_sdk.app import App
+from soar_sdk.params import OnPollParams
+
+
+def test_on_poll_decoration_fails_when_used_more_than_once(simple_app: App):
+    """Test that the on_poll decorator can only be used once per app."""
+
+    @simple_app.on_poll()
+    def on_poll_function(params: OnPollParams) -> Iterator[dict]:
+        yield {"data": "test"}
+
+    with pytest.raises(TypeError) as exception_info:
+
+        @simple_app.on_poll()
+        def second_on_poll(params: OnPollParams) -> Iterator[dict]:
+            yield {"data": "another test"}
+
+    assert "The 'on_poll' decorator can only be used once per App instance." in str(
+        exception_info
+    )
path: tests/test_on_poll.py
user: login: phantom-jacob
body: Nit: prefer `match`. Also, I usually try and make it possible to change the error message slightly without breaking tests, by only searching for keywords in the error:

```suggestion
    with pytest.raises(TypeError, match=r'on_poll.+once per'):

        @simple_app.on_poll()
        def second_on_poll(params: OnPollParams) -> Iterator[dict]:
            yield {"data": "another test"}

```
created_at: 2025-05-20T17:46:12Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,152 @@
+#!/usr/bin/python
+from collections.abc import Iterator
+import pytest
+from unittest import mock
+
+from soar_sdk.app import App
+from soar_sdk.params import OnPollParams
+
+
+def test_on_poll_decoration_fails_when_used_more_than_once(simple_app: App):
+    """Test that the on_poll decorator can only be used once per app."""
+
+    @simple_app.on_poll()
+    def on_poll_function(params: OnPollParams) -> Iterator[dict]:
+        yield {"data": "test"}
+
+    with pytest.raises(TypeError) as exception_info:
+
+        @simple_app.on_poll()
+        def second_on_poll(params: OnPollParams) -> Iterator[dict]:
+            yield {"data": "another test"}
+
+    assert "The 'on_poll' decorator can only be used once per App instance." in str(
+        exception_info
+    )
+
+
+def test_on_poll_decoration_fails_when_not_generator(simple_app: App):
+    """Test that the on_poll decorator requires a generator function."""
+    with pytest.raises(TypeError) as exception_info:
+
+        @simple_app.on_poll()
+        def on_poll_function(params: OnPollParams):
+            return {"data": "test"}  # Not yielding
+
+    assert (
+        "The on_poll function must be a generator (use 'yield') or return an Iterator."
+        in str(exception_info)
+    )
path: tests/test_on_poll.py
user: login: phantom-jacob
body: Same here, prefer `match`
created_at: 2025-05-20T17:46:32Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,152 @@
+#!/usr/bin/python
+from collections.abc import Iterator
+import pytest
+from unittest import mock
+
+from soar_sdk.app import App
+from soar_sdk.params import OnPollParams
+
+
+def test_on_poll_decoration_fails_when_used_more_than_once(simple_app: App):
+    """Test that the on_poll decorator can only be used once per app."""
+
+    @simple_app.on_poll()
+    def on_poll_function(params: OnPollParams) -> Iterator[dict]:
+        yield {"data": "test"}
+
+    with pytest.raises(TypeError) as exception_info:
+
+        @simple_app.on_poll()
+        def second_on_poll(params: OnPollParams) -> Iterator[dict]:
+            yield {"data": "another test"}
+
+    assert "The 'on_poll' decorator can only be used once per App instance." in str(
+        exception_info
+    )
+
+
+def test_on_poll_decoration_fails_when_not_generator(simple_app: App):
+    """Test that the on_poll decorator requires a generator function."""
+    with pytest.raises(TypeError) as exception_info:
+
+        @simple_app.on_poll()
+        def on_poll_function(params: OnPollParams):
+            return {"data": "test"}  # Not yielding
+
+    assert (
+        "The on_poll function must be a generator (use 'yield') or return an Iterator."
+        in str(exception_info)
+    )
+
+
+def test_on_poll_decoration_with_meta(simple_app: App):
+    """Test that the on_poll decorator properly sets up metadata."""
+
+    @simple_app.on_poll()
+    def on_poll_function(params: OnPollParams) -> Iterator[dict]:
+        """
+        This polling function does nothing for now.
+        """
+        yield {"data": "test"}
+
+    action = simple_app.actions_provider.get_action("on_poll")
+    assert action is not None
+    assert action.meta.action == "on poll"
+    assert action == on_poll_function
+
+
+def test_on_poll_function_called_with_params(simple_app: App):
+    """Test that the on_poll function is called with parameters."""
+    poll_fn = mock.Mock(return_value=iter([{"data": "test"}]))
+
+    @simple_app.on_poll()
+    def on_poll_function(params: OnPollParams) -> Iterator[dict]:
+        poll_fn(params)
+        yield {"data": "test"}
+
+    client_mock = mock.Mock()
+
+    params = OnPollParams(
+        start_time=1000,
+        end_time=2000,
+        container_count=10,
+        artifact_count=100,
+        container_id=1,
+    )
+
+    result = on_poll_function(params, client=client_mock)
+
+    poll_fn.assert_called_once_with(params)
+    assert result is True
+
+
+def test_on_poll_works_with_iterator_functions(simple_app: App):
+    """Test that the on_poll decorator works with functions that return iterators."""
+    with mock.patch("inspect.isgeneratorfunction", return_value=True):
path: tests/test_on_poll.py
user: login: phantom-jacob
body: Doesn't this mock actively break the test? The decorator tries to make it possible to return _either_ an iterator or a generator, doesn't it? Mocking this return value will prevent the test from exercising the "this function returns an iterator but not a generator" code path
created_at: 2025-05-20T17:47:31Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,152 @@
+#!/usr/bin/python
+from collections.abc import Iterator
+import pytest
+from unittest import mock
+
+from soar_sdk.app import App
+from soar_sdk.params import OnPollParams
+
+
+def test_on_poll_decoration_fails_when_used_more_than_once(simple_app: App):
+    """Test that the on_poll decorator can only be used once per app."""
+
+    @simple_app.on_poll()
+    def on_poll_function(params: OnPollParams) -> Iterator[dict]:
+        yield {"data": "test"}
+
+    with pytest.raises(TypeError) as exception_info:
+
+        @simple_app.on_poll()
+        def second_on_poll(params: OnPollParams) -> Iterator[dict]:
+            yield {"data": "another test"}
+
+    assert "The 'on_poll' decorator can only be used once per App instance." in str(
+        exception_info
+    )
+
+
+def test_on_poll_decoration_fails_when_not_generator(simple_app: App):
+    """Test that the on_poll decorator requires a generator function."""
+    with pytest.raises(TypeError) as exception_info:
+
+        @simple_app.on_poll()
+        def on_poll_function(params: OnPollParams):
+            return {"data": "test"}  # Not yielding
+
+    assert (
+        "The on_poll function must be a generator (use 'yield') or return an Iterator."
+        in str(exception_info)
+    )
+
+
+def test_on_poll_decoration_with_meta(simple_app: App):
+    """Test that the on_poll decorator properly sets up metadata."""
+
+    @simple_app.on_poll()
+    def on_poll_function(params: OnPollParams) -> Iterator[dict]:
+        """
+        This polling function does nothing for now.
+        """
+        yield {"data": "test"}
+
+    action = simple_app.actions_provider.get_action("on_poll")
+    assert action is not None
+    assert action.meta.action == "on poll"
+    assert action == on_poll_function
+
+
+def test_on_poll_function_called_with_params(simple_app: App):
+    """Test that the on_poll function is called with parameters."""
+    poll_fn = mock.Mock(return_value=iter([{"data": "test"}]))
+
+    @simple_app.on_poll()
+    def on_poll_function(params: OnPollParams) -> Iterator[dict]:
+        poll_fn(params)
+        yield {"data": "test"}
+
+    client_mock = mock.Mock()
+
+    params = OnPollParams(
+        start_time=1000,
+        end_time=2000,
+        container_count=10,
+        artifact_count=100,
+        container_id=1,
+    )
+
+    result = on_poll_function(params, client=client_mock)
+
+    poll_fn.assert_called_once_with(params)
+    assert result is True
+
+
+def test_on_poll_works_with_iterator_functions(simple_app: App):
+    """Test that the on_poll decorator works with functions that return iterators."""
+    with mock.patch("inspect.isgeneratorfunction", return_value=True):
+
+        @simple_app.on_poll()
+        def on_poll_function(params: OnPollParams) -> Iterator[dict]:
+            # Creating and returning an iterator instead of yielding
+            return iter([{"data": "test artifact 1"}, {"data": "test artifact 2"}])
+
+        client_mock = mock.Mock()
+
+        params = OnPollParams(
+            start_time=1000,
+            end_time=2000,
+            container_count=10,
+            artifact_count=100,
+            container_id=1,
+        )
+
+        result = on_poll_function(params, client=client_mock)
+
+        assert result is True
+
+
+def test_on_poll_empty_iterator(simple_app: App):
+    """Test that the on_poll function works with empty iterators."""
+    with mock.patch("inspect.isgeneratorfunction", return_value=True):
path: tests/test_on_poll.py
user: login: phantom-jacob
body: Same here, seems to be a harmful mock
created_at: 2025-05-20T17:47:46Z
author_association: COLLABORATOR
diff_hunk: @@ -295,6 +299,170 @@ def inner(
 
         return test_con_function
 
+    def on_poll(self) -> Callable[[Callable], Action]:
path: src/soar_sdk/app.py
user: login: phantom-jacob
body: Discussion (not for this PR): the proliferation of these decorators has me starting to think we should consider factoring them out into class-based decorators in their own file, with entrypoints attached to the `App` class
created_at: 2025-05-20T17:50:58Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,89 @@
+#!/usr/bin/env python
+from typing import Optional, Any, Union
+
+
+class Artifact:
path: src/soar_sdk/artifact.py
user: login: tapishj-splunk
body: Consider adding this file and container.py to a `src/soar_sdk/models` directory for organization purposes.
created_at: 2025-05-20T18:46:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -295,6 +299,170 @@ def inner(
 
         return test_con_function
 
+    def on_poll(self) -> Callable[[Callable], Action]:
+        """
+        Decorator for the on_poll action.
+
+        The decorated function must be a generator (using yield) or return an Iterator that yields Container and/or Artifact objects. Only one on_poll action is allowed per app.
+
+        Usage:
+        If a Container is yielded first, all subsequent Artifacts will be added to that container unless they already have a `container_id`.
+        If an `Artifact` is yielded without a container and no `container_id` is set, it will be skipped.
+        """
+
+        def on_poll_decorator(function: Callable) -> Action:
+            if self.actions_provider.get_action("on_poll"):
+                raise TypeError(
+                    "The 'on_poll' decorator can only be used once per App instance."
+                )
+
+            # Check if function is generator function or has a return type annotation of iterator
+            is_generator = inspect.isgeneratorfunction(function)
+            signature = inspect.signature(function)
+            has_iterator_return = False
+
+            # Check if the return annotation is an Iterator type
+            if (
+                signature.return_annotation != inspect.Signature.empty
+                and hasattr(signature.return_annotation, "__origin__")
+                and signature.return_annotation.__origin__ is Iterator
+            ):
+                has_iterator_return = True
+
+            if not (is_generator or has_iterator_return):
+                raise TypeError(
+                    "The on_poll function must be a generator (use 'yield') or return an Iterator."
+                )
+
+            action_identifier = "on_poll"
+            action_name = "on poll"
+
+            # Use OnPollParams for on_poll actions
+            validated_params_class = OnPollParams
+
+            @action_protocol
+            @wraps(function)
+            def inner(
+                params: OnPollParams,
+                client: SOARClient = self.actions_provider.soar_client,
+                *args: Any,  # noqa: ANN401
+                **kwargs: Any,  # noqa: ANN401
+            ) -> bool:
+                try:
+                    # Validate poll params
+                    if validated_params_class is not None:
+                        try:
+                            action_params = validated_params_class.parse_obj(params)
+                        except Exception as e:
+                            client.save_progress(f"Parameter validation error: {e!s}")
+                            return self._adapt_action_result(
+                                ActionResult(
+                                    status=False, message=f"Invalid parameters: {e!s}"
+                                ),
+                                client,
+                            )
+                        params = action_params
+
+                    kwargs = self._build_magic_args(function, client=client, **kwargs)
+
+                    result = function(params, *args, **kwargs)
+
+                    # Check if container_id is provided in params
+                    container_id = getattr(params, "container_id", None)
+                    container_created = False
+
+                    for item in result:
+                        # Check if the item is a Container
+                        if isinstance(item, Container):
+                            # TODO: Remove ignores
+                            container = item.to_dict()  # Convert for saving
+                            ret_val, message, cid = client.save_container(container)  # type: ignore[attr-defined]
+                            client.save_progress(
+                                f"Creating container: {container['name']}"
+                            )
+
+                            if ret_val:
+                                container_id = cid
+                                container_created = True
+                                item.container_id = (
+                                    container_id  # Store the container_id for reference
+                                )
+                            elif "duplicate container found" in message.lower():
+                                client.save_progress(
+                                    "Duplicate container found, reusing existing container"
+                                )
+                                container_id = cid
+                                container_created = True
+                                item.container_id = container_id
+                            continue
+
+                        # Check for Artifact
+                        if not isinstance(item, Artifact):
+                            client.save_progress(
+                                f"Warning: Item is not a Container or Artifact, skipping: {item}"
+                            )
+                            continue
+
+                        artifact_dict = item.to_dict()  # Convert for saving
+
+                        if (
+                            not container_id
+                            and not container_created
+                            and "container_id" not in artifact_dict
+                        ):
+                            # No container for this artifact
+                            client.save_progress(
+                                f"Warning: Artifact has no container, skipping: {item}"
+                            )
+                            continue
+
+                        if container_id and "container_id" not in artifact_dict:
+                            # Set the container_id
+                            artifact_dict["container_id"] = container_id
+                            item.container_id = container_id
+
+                        # TODO: Remove ignores
+                        client.save_artifacts([artifact_dict])  # type: ignore[attr-defined]
+                        client.save_progress(
+                            f"Added artifact: {artifact_dict.get('name', 'Unnamed artifact')}"
+                        )
+
+                    return self._adapt_action_result(
+                        ActionResult(status=True, message="Polling complete"), client
+                    )
+                except Exception as e:
path: src/soar_sdk/app.py
user: login: tapishj-splunk
body: Add `client.add_exception(e)` so the database gets updated with the error too. Also for regular actions and test_connectivity we swallow ActionFailure and AssetMisconfiguration exceptions. I think to keep it consistent we should do that for on poll too
created_at: 2025-05-20T18:51:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,89 @@
+#!/usr/bin/env python
+from typing import Optional, Any, Union
+
+
+class Artifact:
path: src/soar_sdk/artifact.py
user: login: grokas-splunk
body: Yup agreed, updated
created_at: 2025-05-20T23:24:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,119 @@
+#!/usr/bin/env python
+from typing import Optional, Any, Union
+
+
+class Container:
path: src/soar_sdk/container.py
user: login: grokas-splunk
body: Agreed, updated
created_at: 2025-05-20T23:24:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -132,3 +132,37 @@ def _to_json_schema(cls) -> dict[str, InputFieldSpecification]:
             params[field_name] = params_field
 
         return params
+
+
+class OnPollParams(Params):
+    """
+    Parameters for on_poll.
+
+    Used to define the specific parameters for the on_poll action.
+    """
+
+    start_time: int = Param(
+        description="Start of time range, in epoch time (milliseconds).",
+        required=False,
+    )
+
+    end_time: int = Param(
+        description="End of time range, in epoch time (milliseconds).",
+        required=False,
+    )
+
+    container_count: int = Param(
+        description="Maximum number of container records to query for.",
+        required=False,
+    )
+
+    artifact_count: int = Param(
+        description="Maximum number of artifact records to query for.",
+        required=False,
+    )
+
+    container_id: str = Param(
+        description="Container IDs to limit the ingestion to.",
+        required=False,
+        allow_list=True,
+    )
path: src/soar_sdk/params.py
user: login: grokas-splunk
body: Yeah, can be a comma-separated list of container IDs. I just used the description from the official docs on on_poll but should we update to be more explicit?
created_at: 2025-05-20T23:29:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -56,5 +61,38 @@ def reverse_string(
     return ReverseStringOutput(reversed_string=reversed_string)
 
 
+@app.on_poll()
+def on_poll(
+    params: OnPollParams, client: SOARClient, asset: Asset
+) -> Iterator[Union[Container, Artifact]]:
+    # Create container first for artifacts
+    yield Container(
+        name="Network Alerts",
+        description="Some network-related alerts",
+        severity="medium",
+    )
+
+    # Simulate collecting 2 network artifacts that will be put in the network alerts container
+    for i in range(2):
+        client.save_progress(f"Processing network artifact {i}")
path: tests/example_app/src/app.py
user: login: grokas-splunk
body: Yup, updated 👍
created_at: 2025-05-20T23:29:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -56,5 +61,38 @@ def reverse_string(
     return ReverseStringOutput(reversed_string=reversed_string)
 
 
+@app.on_poll()
+def on_poll(
+    params: OnPollParams, client: SOARClient, asset: Asset
+) -> Iterator[Union[Container, Artifact]]:
+    # Create container first for artifacts
+    yield Container(
+        name="Network Alerts",
+        description="Some network-related alerts",
+        severity="medium",
+    )
+
+    # Simulate collecting 2 network artifacts that will be put in the network alerts container
+    for i in range(2):
+        client.save_progress(f"Processing network artifact {i}")
+
+        artifact = Artifact(
+            name=f"Network Alert {i + 1}",
+            label="alert",
+            severity="medium",
+            source_data_identifier=asset.base_url,
+            type="network",
+            description=f"Example network alert {i + 1} from polling operation",
+            data={
+                "alert_id": f"testalert-{datetime.now(timezone.utc).strftime('%Y%m%d')}-{i + 1}",
+                "source_ip": f"10.0.0.{i + 1}",
+                "destination_ip": "192.168.0.1",
+                "protocol": "TCP",
+            },
+        )
path: tests/example_app/src/app.py
user: login: grokas-splunk
body: Agreed think that does make more sense, updated
created_at: 2025-05-20T23:29:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,152 @@
+#!/usr/bin/python
+from collections.abc import Iterator
+import pytest
+from unittest import mock
+
+from soar_sdk.app import App
+from soar_sdk.params import OnPollParams
+
+
+def test_on_poll_decoration_fails_when_used_more_than_once(simple_app: App):
+    """Test that the on_poll decorator can only be used once per app."""
+
+    @simple_app.on_poll()
+    def on_poll_function(params: OnPollParams) -> Iterator[dict]:
+        yield {"data": "test"}
+
+    with pytest.raises(TypeError) as exception_info:
+
+        @simple_app.on_poll()
+        def second_on_poll(params: OnPollParams) -> Iterator[dict]:
+            yield {"data": "another test"}
+
+    assert "The 'on_poll' decorator can only be used once per App instance." in str(
+        exception_info
+    )
+
+
+def test_on_poll_decoration_fails_when_not_generator(simple_app: App):
+    """Test that the on_poll decorator requires a generator function."""
+    with pytest.raises(TypeError) as exception_info:
+
+        @simple_app.on_poll()
+        def on_poll_function(params: OnPollParams):
+            return {"data": "test"}  # Not yielding
+
+    assert (
+        "The on_poll function must be a generator (use 'yield') or return an Iterator."
+        in str(exception_info)
+    )
path: tests/test_on_poll.py
user: login: grokas-splunk
body: Updated
created_at: 2025-05-20T23:29:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,152 @@
+#!/usr/bin/python
+from collections.abc import Iterator
+import pytest
+from unittest import mock
+
+from soar_sdk.app import App
+from soar_sdk.params import OnPollParams
+
+
+def test_on_poll_decoration_fails_when_used_more_than_once(simple_app: App):
+    """Test that the on_poll decorator can only be used once per app."""
+
+    @simple_app.on_poll()
+    def on_poll_function(params: OnPollParams) -> Iterator[dict]:
+        yield {"data": "test"}
+
+    with pytest.raises(TypeError) as exception_info:
+
+        @simple_app.on_poll()
+        def second_on_poll(params: OnPollParams) -> Iterator[dict]:
+            yield {"data": "another test"}
+
+    assert "The 'on_poll' decorator can only be used once per App instance." in str(
+        exception_info
+    )
path: tests/test_on_poll.py
user: login: grokas-splunk
body: Updated 👍 
created_at: 2025-05-20T23:29:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,152 @@
+#!/usr/bin/python
+from collections.abc import Iterator
+import pytest
+from unittest import mock
+
+from soar_sdk.app import App
+from soar_sdk.params import OnPollParams
+
+
+def test_on_poll_decoration_fails_when_used_more_than_once(simple_app: App):
+    """Test that the on_poll decorator can only be used once per app."""
+
+    @simple_app.on_poll()
+    def on_poll_function(params: OnPollParams) -> Iterator[dict]:
+        yield {"data": "test"}
+
+    with pytest.raises(TypeError) as exception_info:
+
+        @simple_app.on_poll()
+        def second_on_poll(params: OnPollParams) -> Iterator[dict]:
+            yield {"data": "another test"}
+
+    assert "The 'on_poll' decorator can only be used once per App instance." in str(
+        exception_info
+    )
+
+
+def test_on_poll_decoration_fails_when_not_generator(simple_app: App):
+    """Test that the on_poll decorator requires a generator function."""
+    with pytest.raises(TypeError) as exception_info:
+
+        @simple_app.on_poll()
+        def on_poll_function(params: OnPollParams):
+            return {"data": "test"}  # Not yielding
+
+    assert (
+        "The on_poll function must be a generator (use 'yield') or return an Iterator."
+        in str(exception_info)
+    )
+
+
+def test_on_poll_decoration_with_meta(simple_app: App):
+    """Test that the on_poll decorator properly sets up metadata."""
+
+    @simple_app.on_poll()
+    def on_poll_function(params: OnPollParams) -> Iterator[dict]:
+        """
+        This polling function does nothing for now.
+        """
+        yield {"data": "test"}
+
+    action = simple_app.actions_provider.get_action("on_poll")
+    assert action is not None
+    assert action.meta.action == "on poll"
+    assert action == on_poll_function
+
+
+def test_on_poll_function_called_with_params(simple_app: App):
+    """Test that the on_poll function is called with parameters."""
+    poll_fn = mock.Mock(return_value=iter([{"data": "test"}]))
+
+    @simple_app.on_poll()
+    def on_poll_function(params: OnPollParams) -> Iterator[dict]:
+        poll_fn(params)
+        yield {"data": "test"}
+
+    client_mock = mock.Mock()
+
+    params = OnPollParams(
+        start_time=1000,
+        end_time=2000,
+        container_count=10,
+        artifact_count=100,
+        container_id=1,
+    )
+
+    result = on_poll_function(params, client=client_mock)
+
+    poll_fn.assert_called_once_with(params)
+    assert result is True
+
+
+def test_on_poll_works_with_iterator_functions(simple_app: App):
+    """Test that the on_poll decorator works with functions that return iterators."""
+    with mock.patch("inspect.isgeneratorfunction", return_value=True):
path: tests/test_on_poll.py
user: login: grokas-splunk
body: Yeah, these tests were broken, didn't get around to cleaning them up. Updated, should be better now 👍
created_at: 2025-05-20T23:31:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,152 @@
+#!/usr/bin/python
+from collections.abc import Iterator
+import pytest
+from unittest import mock
+
+from soar_sdk.app import App
+from soar_sdk.params import OnPollParams
+
+
+def test_on_poll_decoration_fails_when_used_more_than_once(simple_app: App):
+    """Test that the on_poll decorator can only be used once per app."""
+
+    @simple_app.on_poll()
+    def on_poll_function(params: OnPollParams) -> Iterator[dict]:
+        yield {"data": "test"}
+
+    with pytest.raises(TypeError) as exception_info:
+
+        @simple_app.on_poll()
+        def second_on_poll(params: OnPollParams) -> Iterator[dict]:
+            yield {"data": "another test"}
+
+    assert "The 'on_poll' decorator can only be used once per App instance." in str(
+        exception_info
+    )
+
+
+def test_on_poll_decoration_fails_when_not_generator(simple_app: App):
+    """Test that the on_poll decorator requires a generator function."""
+    with pytest.raises(TypeError) as exception_info:
+
+        @simple_app.on_poll()
+        def on_poll_function(params: OnPollParams):
+            return {"data": "test"}  # Not yielding
+
+    assert (
+        "The on_poll function must be a generator (use 'yield') or return an Iterator."
+        in str(exception_info)
+    )
+
+
+def test_on_poll_decoration_with_meta(simple_app: App):
+    """Test that the on_poll decorator properly sets up metadata."""
+
+    @simple_app.on_poll()
+    def on_poll_function(params: OnPollParams) -> Iterator[dict]:
+        """
+        This polling function does nothing for now.
+        """
+        yield {"data": "test"}
+
+    action = simple_app.actions_provider.get_action("on_poll")
+    assert action is not None
+    assert action.meta.action == "on poll"
+    assert action == on_poll_function
+
+
+def test_on_poll_function_called_with_params(simple_app: App):
+    """Test that the on_poll function is called with parameters."""
+    poll_fn = mock.Mock(return_value=iter([{"data": "test"}]))
+
+    @simple_app.on_poll()
+    def on_poll_function(params: OnPollParams) -> Iterator[dict]:
+        poll_fn(params)
+        yield {"data": "test"}
+
+    client_mock = mock.Mock()
+
+    params = OnPollParams(
+        start_time=1000,
+        end_time=2000,
+        container_count=10,
+        artifact_count=100,
+        container_id=1,
+    )
+
+    result = on_poll_function(params, client=client_mock)
+
+    poll_fn.assert_called_once_with(params)
+    assert result is True
+
+
+def test_on_poll_works_with_iterator_functions(simple_app: App):
+    """Test that the on_poll decorator works with functions that return iterators."""
+    with mock.patch("inspect.isgeneratorfunction", return_value=True):
+
+        @simple_app.on_poll()
+        def on_poll_function(params: OnPollParams) -> Iterator[dict]:
+            # Creating and returning an iterator instead of yielding
+            return iter([{"data": "test artifact 1"}, {"data": "test artifact 2"}])
+
+        client_mock = mock.Mock()
+
+        params = OnPollParams(
+            start_time=1000,
+            end_time=2000,
+            container_count=10,
+            artifact_count=100,
+            container_id=1,
+        )
+
+        result = on_poll_function(params, client=client_mock)
+
+        assert result is True
+
+
+def test_on_poll_empty_iterator(simple_app: App):
+    """Test that the on_poll function works with empty iterators."""
+    with mock.patch("inspect.isgeneratorfunction", return_value=True):
path: tests/test_on_poll.py
user: login: grokas-splunk
body: Updated as well
created_at: 2025-05-20T23:31:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,89 @@
+#!/usr/bin/env python
+from typing import Optional, Any, Union
+
+
+class Artifact:
path: src/soar_sdk/artifact.py
user: login: grokas-splunk
body: Agreed, definitely cleaner updated it
created_at: 2025-05-20T23:31:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -295,6 +299,170 @@ def inner(
 
         return test_con_function
 
+    def on_poll(self) -> Callable[[Callable], Action]:
+        """
+        Decorator for the on_poll action.
+
+        The decorated function must be a generator (using yield) or return an Iterator that yields Container and/or Artifact objects. Only one on_poll action is allowed per app.
+
+        Usage:
+        If a Container is yielded first, all subsequent Artifacts will be added to that container unless they already have a `container_id`.
+        If an `Artifact` is yielded without a container and no `container_id` is set, it will be skipped.
+        """
+
+        def on_poll_decorator(function: Callable) -> Action:
+            if self.actions_provider.get_action("on_poll"):
+                raise TypeError(
+                    "The 'on_poll' decorator can only be used once per App instance."
+                )
+
+            # Check if function is generator function or has a return type annotation of iterator
+            is_generator = inspect.isgeneratorfunction(function)
+            signature = inspect.signature(function)
+            has_iterator_return = False
+
+            # Check if the return annotation is an Iterator type
+            if (
+                signature.return_annotation != inspect.Signature.empty
+                and hasattr(signature.return_annotation, "__origin__")
+                and signature.return_annotation.__origin__ is Iterator
+            ):
+                has_iterator_return = True
+
+            if not (is_generator or has_iterator_return):
+                raise TypeError(
+                    "The on_poll function must be a generator (use 'yield') or return an Iterator."
+                )
+
+            action_identifier = "on_poll"
+            action_name = "on poll"
+
+            # Use OnPollParams for on_poll actions
+            validated_params_class = OnPollParams
+
+            @action_protocol
+            @wraps(function)
+            def inner(
+                params: OnPollParams,
+                client: SOARClient = self.actions_provider.soar_client,
+                *args: Any,  # noqa: ANN401
+                **kwargs: Any,  # noqa: ANN401
+            ) -> bool:
+                try:
+                    # Validate poll params
+                    if validated_params_class is not None:
+                        try:
+                            action_params = validated_params_class.parse_obj(params)
+                        except Exception as e:
+                            client.save_progress(f"Parameter validation error: {e!s}")
+                            return self._adapt_action_result(
+                                ActionResult(
+                                    status=False, message=f"Invalid parameters: {e!s}"
+                                ),
+                                client,
+                            )
+                        params = action_params
+
+                    kwargs = self._build_magic_args(function, client=client, **kwargs)
+
+                    result = function(params, *args, **kwargs)
+
+                    # Check if container_id is provided in params
+                    container_id = getattr(params, "container_id", None)
+                    container_created = False
+
+                    for item in result:
+                        # Check if the item is a Container
+                        if isinstance(item, Container):
+                            # TODO: Remove ignores
+                            container = item.to_dict()  # Convert for saving
+                            ret_val, message, cid = client.save_container(container)  # type: ignore[attr-defined]
+                            client.save_progress(
+                                f"Creating container: {container['name']}"
+                            )
+
+                            if ret_val:
+                                container_id = cid
+                                container_created = True
+                                item.container_id = (
+                                    container_id  # Store the container_id for reference
+                                )
+                            elif "duplicate container found" in message.lower():
+                                client.save_progress(
+                                    "Duplicate container found, reusing existing container"
+                                )
+                                container_id = cid
+                                container_created = True
+                                item.container_id = container_id
+                            continue
+
+                        # Check for Artifact
+                        if not isinstance(item, Artifact):
+                            client.save_progress(
+                                f"Warning: Item is not a Container or Artifact, skipping: {item}"
+                            )
+                            continue
+
+                        artifact_dict = item.to_dict()  # Convert for saving
+
+                        if (
+                            not container_id
+                            and not container_created
+                            and "container_id" not in artifact_dict
+                        ):
+                            # No container for this artifact
+                            client.save_progress(
+                                f"Warning: Artifact has no container, skipping: {item}"
+                            )
+                            continue
+
+                        if container_id and "container_id" not in artifact_dict:
+                            # Set the container_id
+                            artifact_dict["container_id"] = container_id
+                            item.container_id = container_id
+
+                        # TODO: Remove ignores
+                        client.save_artifacts([artifact_dict])  # type: ignore[attr-defined]
+                        client.save_progress(
+                            f"Added artifact: {artifact_dict.get('name', 'Unnamed artifact')}"
+                        )
+
+                    return self._adapt_action_result(
+                        ActionResult(status=True, message="Polling complete"), client
+                    )
+                except Exception as e:
path: src/soar_sdk/app.py
user: login: grokas-splunk
body: Agreed better to be consistent, updated 👍
created_at: 2025-05-20T23:32:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -295,6 +299,170 @@ def inner(
 
         return test_con_function
 
+    def on_poll(self) -> Callable[[Callable], Action]:
path: src/soar_sdk/app.py
user: login: grokas-splunk
body: Agreed, I like the idea of class-based decorators. Should help keep things cleaner and more maintainable. Should I make a ticket for the SDK epic on this refactor?
created_at: 2025-05-20T23:36:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -295,6 +299,177 @@ def inner(
 
         return test_con_function
 
+    def on_poll(self) -> Callable[[Callable], Action]:
+        """
+        Decorator for the on_poll action.
+
+        The decorated function must be a generator (using yield) or return an Iterator that yields Container and/or Artifact objects. Only one on_poll action is allowed per app.
+
+        Usage:
+        If a Container is yielded first, all subsequent Artifacts will be added to that container unless they already have a `container_id`.
+        If an `Artifact` is yielded without a container and no `container_id` is set, it will be skipped.
+        """
+
+        def on_poll_decorator(function: Callable) -> Action:
+            if self.actions_provider.get_action("on_poll"):
+                raise TypeError(
+                    "The 'on_poll' decorator can only be used once per App instance."
+                )
+
+            # Check if function is generator function or has a return type annotation of iterator
+            is_generator = inspect.isgeneratorfunction(function)
+            signature = inspect.signature(function)
+            has_iterator_return = False
+
+            # Check if the return annotation is an Iterator type
+            if (
+                signature.return_annotation != inspect.Signature.empty
+                and hasattr(signature.return_annotation, "__origin__")
+                and signature.return_annotation.__origin__ is Iterator
+            ):
+                has_iterator_return = True
+
+            if not (is_generator or has_iterator_return):
+                raise TypeError(
+                    "The on_poll function must be a generator (use 'yield') or return an Iterator."
+                )
+
+            action_identifier = "on_poll"
+            action_name = "on poll"
+
+            # Use OnPollParams for on_poll actions
+            validated_params_class = OnPollParams
+
+            @action_protocol
+            @wraps(function)
+            def inner(
+                params: OnPollParams,
+                client: SOARClient = self.actions_provider.soar_client,
+                *args: Any,  # noqa: ANN401
+                **kwargs: Any,  # noqa: ANN401
+            ) -> bool:
+                try:
+                    # Validate poll params
+                    try:
+                        action_params = validated_params_class.parse_obj(params)
+                    except Exception as e:
+                        client.save_progress(f"Parameter validation error: {e!s}")
+                        return self._adapt_action_result(
+                            ActionResult(
+                                status=False, message=f"Invalid parameters: {e!s}"
+                            ),
+                            client,
+                        )
+                    params = action_params
path: src/soar_sdk/app.py
user: login: tapishj-splunk
body: Is this re-defintion needed? Can't we just keep using action_params
created_at: 2025-05-22T21:26:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -295,6 +299,177 @@ def inner(
 
         return test_con_function
 
+    def on_poll(self) -> Callable[[Callable], Action]:
+        """
+        Decorator for the on_poll action.
+
+        The decorated function must be a generator (using yield) or return an Iterator that yields Container and/or Artifact objects. Only one on_poll action is allowed per app.
+
+        Usage:
+        If a Container is yielded first, all subsequent Artifacts will be added to that container unless they already have a `container_id`.
+        If an `Artifact` is yielded without a container and no `container_id` is set, it will be skipped.
+        """
+
+        def on_poll_decorator(function: Callable) -> Action:
+            if self.actions_provider.get_action("on_poll"):
+                raise TypeError(
+                    "The 'on_poll' decorator can only be used once per App instance."
+                )
+
+            # Check if function is generator function or has a return type annotation of iterator
+            is_generator = inspect.isgeneratorfunction(function)
+            signature = inspect.signature(function)
+            has_iterator_return = False
+
+            # Check if the return annotation is an Iterator type
+            if (
+                signature.return_annotation != inspect.Signature.empty
+                and hasattr(signature.return_annotation, "__origin__")
+                and signature.return_annotation.__origin__ is Iterator
+            ):
+                has_iterator_return = True
+
+            if not (is_generator or has_iterator_return):
+                raise TypeError(
+                    "The on_poll function must be a generator (use 'yield') or return an Iterator."
+                )
+
+            action_identifier = "on_poll"
+            action_name = "on poll"
+
+            # Use OnPollParams for on_poll actions
+            validated_params_class = OnPollParams
+
+            @action_protocol
+            @wraps(function)
+            def inner(
+                params: OnPollParams,
+                client: SOARClient = self.actions_provider.soar_client,
+                *args: Any,  # noqa: ANN401
+                **kwargs: Any,  # noqa: ANN401
+            ) -> bool:
+                try:
+                    # Validate poll params
+                    try:
+                        action_params = validated_params_class.parse_obj(params)
+                    except Exception as e:
+                        client.save_progress(f"Parameter validation error: {e!s}")
+                        return self._adapt_action_result(
+                            ActionResult(
+                                status=False, message=f"Invalid parameters: {e!s}"
+                            ),
+                            client,
+                        )
+                    params = action_params
path: src/soar_sdk/app.py
user: login: grokas-splunk
body: Yeah not needed, updated to be cleaner
created_at: 2025-05-23T00:35:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -132,3 +132,37 @@ def _to_json_schema(cls) -> dict[str, InputFieldSpecification]:
             params[field_name] = params_field
 
         return params
+
+
+class OnPollParams(Params):
+    """
+    Parameters for on_poll.
+
+    Used to define the specific parameters for the on_poll action.
+    """
+
+    start_time: int = Param(
+        description="Start of time range, in epoch time (milliseconds).",
+        required=False,
+    )
+
+    end_time: int = Param(
+        description="End of time range, in epoch time (milliseconds).",
+        required=False,
+    )
+
+    container_count: int = Param(
+        description="Maximum number of container records to query for.",
+        required=False,
+    )
+
+    artifact_count: int = Param(
+        description="Maximum number of artifact records to query for.",
+        required=False,
+    )
+
+    container_id: str = Param(
+        description="Container IDs to limit the ingestion to.",
+        required=False,
+        allow_list=True,
+    )
path: src/soar_sdk/params.py
user: login: phantom-jacob
body: Yeah let's be more explicit here
```suggestion
    container_id: str = Param(
        description="Comma-separated list of container IDs to limit the ingestion to.",
        required=False,
        allow_list=True,
    )
```
created_at: 2025-05-23T06:46:08Z
author_association: COLLABORATOR
diff_hunk: @@ -295,6 +299,170 @@ def inner(
 
         return test_con_function
 
+    def on_poll(self) -> Callable[[Callable], Action]:
path: src/soar_sdk/app.py
user: login: phantom-jacob
body: Yeah let's do that. It's best to refactor early and often
created_at: 2025-05-23T06:46:34Z
author_association: COLLABORATOR
diff_hunk: @@ -350,22 +350,20 @@ def inner(
             ) -> bool:
                 try:
                     # Validate poll params
-                    if validated_params_class is not None:
-                        try:
-                            action_params = validated_params_class.parse_obj(params)
-                        except Exception as e:
-                            client.save_progress(f"Parameter validation error: {e!s}")
-                            return self._adapt_action_result(
-                                ActionResult(
-                                    status=False, message=f"Invalid parameters: {e!s}"
-                                ),
-                                client,
-                            )
-                        params = action_params
+                    try:
+                        action_params = validated_params_class.parse_obj(params)
+                    except Exception as e:
+                        client.save_progress(f"Parameter validation error: {e!s}")
path: src/soar_sdk/app.py
user: login: phantom-jacob
body: Maybe this should be a separate, quick refactor PR, but we should adopt the logger everywhere instead of the BaseConnector methods

```suggestion
                        logger.info(f"Parameter validation error: {e!s}")
```
created_at: 2025-05-23T06:48:36Z
author_association: COLLABORATOR
diff_hunk: @@ -384,16 +382,15 @@ def inner(
                             if ret_val:
                                 container_id = cid
                                 container_created = True
-                                item.container_id = (
-                                    container_id  # Store the container_id for reference
-                                )
-                            elif "duplicate container found" in message.lower():
-                                client.save_progress(
+                                item.container_id = container_id
+
+                            if (
+                                "duplicate container found" in message.lower()
+                            ):  # pragma: no cover
+                                client.save_progress(  # pragma: no cover
path: src/soar_sdk/app.py
user: login: phantom-jacob
body: Why the `no cover`?
created_at: 2025-05-23T06:49:26Z
author_association: COLLABORATOR
diff_hunk: @@ -430,7 +427,13 @@ def inner(
                     return self._adapt_action_result(
                         ActionResult(status=True, message="Polling complete"), client
                     )
+                except (ActionFailure, AssetMisconfiguration) as e:
path: src/soar_sdk/app.py
user: login: phantom-jacob
body: Isn't `AssetMisconfiguration` a subclass of `ActionFailure`? That would make this clause redundant
```suggestion
                except ActionFailure as e:
```
created_at: 2025-05-23T06:50:16Z
author_association: COLLABORATOR
diff_hunk: @@ -58,7 +58,9 @@ class ActionParameter(BaseModel):
     'context' field containing metadata.
     """
 
-    context: Optional[ParameterContext] = None
+    # Removed to prevent from appearing in parameters unless explicitly provided as an extra field
+    # Was causing issues with the platform during check on context (which was None from here)
+    # context: Optional[ParameterContext] = None # noqa: ERA001
path: src/soar_sdk/input_spec.py
user: login: phantom-jacob
body: We should probably have a follow-up ticket in the SDK epic to go find out what context is really supposed to be and why it exists and if we can get rid of it
created_at: 2025-05-23T06:51:24Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,38 @@
+#!/usr/bin/env python
path: src/soar_sdk/models/artifact.py
user: login: phantom-jacob
body: Shouldn't need a shebang since the file shouldn't be executable
```suggestion
```
created_at: 2025-05-23T06:51:46Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,38 @@
+#!/usr/bin/env python
+from typing import Optional, Any, Union
+from pydantic import BaseModel
+
+
+class Artifact(BaseModel):
+    """
+    Represents an artifact to be created during on_poll.
+
+    This class allows users to create and configure artifacts when yielding from an on_poll function.
+    """
+
+    class Config:
+        extra = "forbid"
+
+    name: Optional[str] = None
+    label: Optional[str] = None
+    description: Optional[str] = None
+    type: Optional[str] = None
+    severity: Optional[str] = None
+    source_data_identifier: Optional[str] = None
+    container_id: Optional[int] = None
+    data: Optional[dict[str, Any]] = None
+    run_automation: bool = False
+    owner_id: Optional[Union[int, str]] = None
+    cef: Optional[dict[str, Any]] = None
+    cef_types: Optional[dict[str, list[str]]] = None
+    ingest_app_id: Optional[Union[int, str]] = None
+    tags: Optional[Union[list[str], str]] = None
+    start_time: Optional[str] = None
+    end_time: Optional[str] = None
+    kill_chain: Optional[str] = None
path: src/soar_sdk/models/artifact.py
user: login: phantom-jacob
body: It seems suspicious to me that all of these are optional.... I bet they're not all optional in practice (like `name`, `label`, and `cef`). You may have to go digging into the actual REST API to figure out which are required though
created_at: 2025-05-23T06:52:58Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,49 @@
+#!/usr/bin/env python
+from pydantic import BaseModel
+from typing import Optional, Any, Union
+
+
+class Container(BaseModel):
+    """
+    Represents a container to be created during on_poll.
+
+    This class allows users to specify container properties when yielding from an on_poll function.
+    """
+
+    class Config:
+        extra = "forbid"
+
+    name: str
+    label: Optional[str] = None
path: src/soar_sdk/models/container.py
user: login: phantom-jacob
body: `label` is definitely not optional
created_at: 2025-05-23T07:02:14Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,49 @@
+#!/usr/bin/env python
+from pydantic import BaseModel
+from typing import Optional, Any, Union
+
+
+class Container(BaseModel):
+    """
+    Represents a container to be created during on_poll.
+
+    This class allows users to specify container properties when yielding from an on_poll function.
+    """
+
+    class Config:
+        extra = "forbid"
+
+    name: str
+    label: Optional[str] = None
+    description: Optional[str] = None
+    source_data_identifier: Optional[str] = None
+    severity: Optional[str] = None
+    status: Optional[str] = None
+    tags: Optional[Union[list[str], str]] = None
+    owner_id: Optional[Union[int, str]] = None
+    sensitivity: Optional[str] = None
+    artifacts: Optional[list[dict[str, Any]]] = None
+    asset_id: Optional[int] = None
+    close_time: Optional[str] = None
+    custom_fields: Optional[dict[str, Any]] = None
+    data: Optional[dict[str, Any]] = None
+    due_time: Optional[str] = None
+    end_time: Optional[str] = None
+    ingest_app_id: Optional[int] = None
path: src/soar_sdk/models/container.py
user: login: phantom-jacob
body: This is a field that should be added automatically
created_at: 2025-05-23T07:02:49Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,49 @@
+#!/usr/bin/env python
+from pydantic import BaseModel
+from typing import Optional, Any, Union
+
+
+class Container(BaseModel):
+    """
+    Represents a container to be created during on_poll.
+
+    This class allows users to specify container properties when yielding from an on_poll function.
+    """
+
+    class Config:
+        extra = "forbid"
+
+    name: str
+    label: Optional[str] = None
+    description: Optional[str] = None
+    source_data_identifier: Optional[str] = None
+    severity: Optional[str] = None
+    status: Optional[str] = None
+    tags: Optional[Union[list[str], str]] = None
+    owner_id: Optional[Union[int, str]] = None
+    sensitivity: Optional[str] = None
+    artifacts: Optional[list[dict[str, Any]]] = None
+    asset_id: Optional[int] = None
+    close_time: Optional[str] = None
+    custom_fields: Optional[dict[str, Any]] = None
+    data: Optional[dict[str, Any]] = None
+    due_time: Optional[str] = None
+    end_time: Optional[str] = None
+    ingest_app_id: Optional[int] = None
+    kill_chain: Optional[str] = None
path: src/soar_sdk/models/container.py
user: login: phantom-jacob
body: Where did you get the list of fields for these models? I think `kill_chain` has been defunct longer than I've been at Splunk
created_at: 2025-05-23T07:03:16Z
author_association: COLLABORATOR
diff_hunk: @@ -295,6 +299,170 @@ def inner(
 
         return test_con_function
 
+    def on_poll(self) -> Callable[[Callable], Action]:
path: src/soar_sdk/app.py
user: login: grokas-splunk
body: Ticket: https://splunk.atlassian.net/browse/PAPP-36063
created_at: 2025-05-23T17:15:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -58,7 +58,9 @@ class ActionParameter(BaseModel):
     'context' field containing metadata.
     """
 
-    context: Optional[ParameterContext] = None
+    # Removed to prevent from appearing in parameters unless explicitly provided as an extra field
+    # Was causing issues with the platform during check on context (which was None from here)
+    # context: Optional[ParameterContext] = None # noqa: ERA001
path: src/soar_sdk/input_spec.py
user: login: grokas-splunk
body: Ticket: https://splunk.atlassian.net/browse/PAPP-36064
created_at: 2025-05-23T17:24:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -132,3 +132,37 @@ def _to_json_schema(cls) -> dict[str, InputFieldSpecification]:
             params[field_name] = params_field
 
         return params
+
+
+class OnPollParams(Params):
+    """
+    Parameters for on_poll.
+
+    Used to define the specific parameters for the on_poll action.
+    """
+
+    start_time: int = Param(
+        description="Start of time range, in epoch time (milliseconds).",
+        required=False,
+    )
+
+    end_time: int = Param(
+        description="End of time range, in epoch time (milliseconds).",
+        required=False,
+    )
+
+    container_count: int = Param(
+        description="Maximum number of container records to query for.",
+        required=False,
+    )
+
+    artifact_count: int = Param(
+        description="Maximum number of artifact records to query for.",
+        required=False,
+    )
+
+    container_id: str = Param(
+        description="Container IDs to limit the ingestion to.",
+        required=False,
+        allow_list=True,
+    )
path: src/soar_sdk/params.py
user: login: grokas-splunk
body: Updated 👍 
created_at: 2025-05-23T17:27:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -350,22 +350,20 @@ def inner(
             ) -> bool:
                 try:
                     # Validate poll params
-                    if validated_params_class is not None:
-                        try:
-                            action_params = validated_params_class.parse_obj(params)
-                        except Exception as e:
-                            client.save_progress(f"Parameter validation error: {e!s}")
-                            return self._adapt_action_result(
-                                ActionResult(
-                                    status=False, message=f"Invalid parameters: {e!s}"
-                                ),
-                                client,
-                            )
-                        params = action_params
+                    try:
+                        action_params = validated_params_class.parse_obj(params)
+                    except Exception as e:
+                        client.save_progress(f"Parameter validation error: {e!s}")
path: src/soar_sdk/app.py
user: login: grokas-splunk
body: Was mostly just used with the `on_poll` stuff so I made the modifications
created_at: 2025-05-23T17:51:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -430,7 +427,13 @@ def inner(
                     return self._adapt_action_result(
                         ActionResult(status=True, message="Polling complete"), client
                     )
+                except (ActionFailure, AssetMisconfiguration) as e:
path: src/soar_sdk/app.py
user: login: grokas-splunk
body: Good point, yeah I've removed here and the other spots it was used
created_at: 2025-05-23T17:55:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,38 @@
+#!/usr/bin/env python
path: src/soar_sdk/models/artifact.py
user: login: grokas-splunk
body: Yup good point
created_at: 2025-05-23T17:55:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,38 @@
+#!/usr/bin/env python
+from typing import Optional, Any, Union
+from pydantic import BaseModel
+
+
+class Artifact(BaseModel):
+    """
+    Represents an artifact to be created during on_poll.
+
+    This class allows users to create and configure artifacts when yielding from an on_poll function.
+    """
+
+    class Config:
+        extra = "forbid"
+
+    name: Optional[str] = None
+    label: Optional[str] = None
+    description: Optional[str] = None
+    type: Optional[str] = None
+    severity: Optional[str] = None
+    source_data_identifier: Optional[str] = None
+    container_id: Optional[int] = None
+    data: Optional[dict[str, Any]] = None
+    run_automation: bool = False
+    owner_id: Optional[Union[int, str]] = None
+    cef: Optional[dict[str, Any]] = None
+    cef_types: Optional[dict[str, list[str]]] = None
+    ingest_app_id: Optional[Union[int, str]] = None
+    tags: Optional[Union[list[str], str]] = None
+    start_time: Optional[str] = None
+    end_time: Optional[str] = None
+    kill_chain: Optional[str] = None
path: src/soar_sdk/models/artifact.py
user: login: grokas-splunk
body: Discussion had, decision to leave as is for now
created_at: 2025-05-23T17:55:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -384,16 +382,15 @@ def inner(
                             if ret_val:
                                 container_id = cid
                                 container_created = True
-                                item.container_id = (
-                                    container_id  # Store the container_id for reference
-                                )
-                            elif "duplicate container found" in message.lower():
-                                client.save_progress(
+                                item.container_id = container_id
+
+                            if (
+                                "duplicate container found" in message.lower()
+                            ):  # pragma: no cover
+                                client.save_progress(  # pragma: no cover
path: src/soar_sdk/app.py
user: login: grokas-splunk
body: Yeah for some reason could not get it to recognize coverage for this even though there is a test for it [(test)](https://github.com/phantomcyber/splunk-soar-sdk/pull/57/files#diff-b7053e39c2cb3d64cea4ebb359c35d59702418ab7b632de61d514a92ab4f759cR180-R195) which I ensured is covering the section. So had to add `no cover`
created_at: 2025-05-23T20:48:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -296,6 +300,172 @@ def inner(
 
         return test_con_function
 
+    def on_poll(self) -> Callable[[Callable], Action]:
+        """
+        Decorator for the on_poll action.
+
+        The decorated function must be a generator (using yield) or return an Iterator that yields Container and/or Artifact objects. Only one on_poll action is allowed per app.
+
+        Usage:
+        If a Container is yielded first, all subsequent Artifacts will be added to that container unless they already have a `container_id`.
+        If an `Artifact` is yielded without a container and no `container_id` is set, it will be skipped.
+        """
+
+        def on_poll_decorator(function: Callable) -> Action:
+            if self.actions_provider.get_action("on_poll"):
+                raise TypeError(
+                    "The 'on_poll' decorator can only be used once per App instance."
+                )
+
+            # Check if function is generator function or has a return type annotation of iterator
+            is_generator = inspect.isgeneratorfunction(function)
+            signature = inspect.signature(function)
+            has_iterator_return = False
+
+            # Check if the return annotation is an Iterator type
+            if (
+                signature.return_annotation != inspect.Signature.empty
+                and hasattr(signature.return_annotation, "__origin__")
+                and signature.return_annotation.__origin__ is Iterator
+            ):
+                has_iterator_return = True
+
+            if not (is_generator or has_iterator_return):
+                raise TypeError(
+                    "The on_poll function must be a generator (use 'yield') or return an Iterator."
+                )
+
+            action_identifier = "on_poll"
+            action_name = "on poll"
+
+            # Use OnPollParams for on_poll actions
+            validated_params_class = OnPollParams
+            logger = self.__logger
+
+            @action_protocol
+            @wraps(function)
+            def inner(
+                params: OnPollParams,
+                client: SOARClient = self.actions_provider.soar_client,
+                *args: Any,  # noqa: ANN401
+                **kwargs: Any,  # noqa: ANN401
+            ) -> bool:
+                try:
+                    # Validate poll params
+                    try:
+                        action_params = validated_params_class.parse_obj(params)
+                    except Exception as e:
+                        logger.info(f"Parameter validation error: {e!s}")
+                        return self._adapt_action_result(
+                            ActionResult(
+                                status=False, message=f"Invalid parameters: {e!s}"
+                            ),
+                            client,
+                        )
+
+                    kwargs = self._build_magic_args(function, client=client, **kwargs)
+
+                    result = function(action_params, *args, **kwargs)
+
+                    # Check if container_id is provided in params
+                    container_id = getattr(params, "container_id", None)
+                    container_created = False
+
+                    for item in result:
+                        # Check if the item is a Container
+                        if isinstance(item, Container):
+                            # TODO: Remove ignores
+                            container = item.to_dict()  # Convert for saving
+                            ret_val, message, cid = client.save_container(container)  # type: ignore[attr-defined]
path: src/soar_sdk/app.py
user: login: phantom-jacob
body: Should be able to remove this TODO now, right?
created_at: 2025-05-27T18:04:01Z
author_association: COLLABORATOR
diff_hunk: @@ -296,6 +300,172 @@ def inner(
 
         return test_con_function
 
+    def on_poll(self) -> Callable[[Callable], Action]:
+        """
+        Decorator for the on_poll action.
+
+        The decorated function must be a generator (using yield) or return an Iterator that yields Container and/or Artifact objects. Only one on_poll action is allowed per app.
+
+        Usage:
+        If a Container is yielded first, all subsequent Artifacts will be added to that container unless they already have a `container_id`.
+        If an `Artifact` is yielded without a container and no `container_id` is set, it will be skipped.
+        """
+
+        def on_poll_decorator(function: Callable) -> Action:
+            if self.actions_provider.get_action("on_poll"):
+                raise TypeError(
+                    "The 'on_poll' decorator can only be used once per App instance."
+                )
+
+            # Check if function is generator function or has a return type annotation of iterator
+            is_generator = inspect.isgeneratorfunction(function)
+            signature = inspect.signature(function)
+            has_iterator_return = False
+
+            # Check if the return annotation is an Iterator type
+            if (
+                signature.return_annotation != inspect.Signature.empty
+                and hasattr(signature.return_annotation, "__origin__")
+                and signature.return_annotation.__origin__ is Iterator
+            ):
+                has_iterator_return = True
+
+            if not (is_generator or has_iterator_return):
+                raise TypeError(
+                    "The on_poll function must be a generator (use 'yield') or return an Iterator."
+                )
+
+            action_identifier = "on_poll"
+            action_name = "on poll"
+
+            # Use OnPollParams for on_poll actions
+            validated_params_class = OnPollParams
+            logger = self.__logger
+
+            @action_protocol
+            @wraps(function)
+            def inner(
+                params: OnPollParams,
+                client: SOARClient = self.actions_provider.soar_client,
+                *args: Any,  # noqa: ANN401
+                **kwargs: Any,  # noqa: ANN401
+            ) -> bool:
+                try:
+                    # Validate poll params
+                    try:
+                        action_params = validated_params_class.parse_obj(params)
+                    except Exception as e:
+                        logger.info(f"Parameter validation error: {e!s}")
+                        return self._adapt_action_result(
+                            ActionResult(
+                                status=False, message=f"Invalid parameters: {e!s}"
+                            ),
+                            client,
+                        )
+
+                    kwargs = self._build_magic_args(function, client=client, **kwargs)
+
+                    result = function(action_params, *args, **kwargs)
+
+                    # Check if container_id is provided in params
+                    container_id = getattr(params, "container_id", None)
+                    container_created = False
+
+                    for item in result:
+                        # Check if the item is a Container
+                        if isinstance(item, Container):
+                            # TODO: Remove ignores
+                            container = item.to_dict()  # Convert for saving
+                            ret_val, message, cid = client.save_container(container)  # type: ignore[attr-defined]
+                            logger.info(f"Creating container: {container['name']}")
+
+                            if ret_val:
+                                container_id = cid
+                                container_created = True
+                                item.container_id = container_id
+
+                            if (
+                                "duplicate container found" in message.lower()
+                            ):  # pragma: no cover
+                                logger.info(  # pragma: no cover
+                                    "Duplicate container found, reusing existing container"
+                                )
path: src/soar_sdk/app.py
user: login: phantom-jacob
body: Tested this locally and only one pragma is actually required. We can also add a comment explaining ourselves:

```suggestion
                            # Covered by test_on_poll::test_on_poll_yields_container_duplicate, but branch coverage
                            # detection on generator functions is wonky
                            if (
                                "duplicate container found" in message.lower()
                            ):  # pragma: no cover
                                logger.info(
                                    "Duplicate container found, reusing existing container"
                                )
```
created_at: 2025-05-27T18:11:00Z
author_association: COLLABORATOR
diff_hunk: @@ -296,6 +300,172 @@ def inner(
 
         return test_con_function
 
+    def on_poll(self) -> Callable[[Callable], Action]:
+        """
+        Decorator for the on_poll action.
+
+        The decorated function must be a generator (using yield) or return an Iterator that yields Container and/or Artifact objects. Only one on_poll action is allowed per app.
+
+        Usage:
+        If a Container is yielded first, all subsequent Artifacts will be added to that container unless they already have a `container_id`.
+        If an `Artifact` is yielded without a container and no `container_id` is set, it will be skipped.
+        """
+
+        def on_poll_decorator(function: Callable) -> Action:
+            if self.actions_provider.get_action("on_poll"):
+                raise TypeError(
+                    "The 'on_poll' decorator can only be used once per App instance."
+                )
+
+            # Check if function is generator function or has a return type annotation of iterator
+            is_generator = inspect.isgeneratorfunction(function)
+            signature = inspect.signature(function)
+            has_iterator_return = False
+
+            # Check if the return annotation is an Iterator type
+            if (
+                signature.return_annotation != inspect.Signature.empty
+                and hasattr(signature.return_annotation, "__origin__")
+                and signature.return_annotation.__origin__ is Iterator
+            ):
+                has_iterator_return = True
+
+            if not (is_generator or has_iterator_return):
+                raise TypeError(
+                    "The on_poll function must be a generator (use 'yield') or return an Iterator."
+                )
+
+            action_identifier = "on_poll"
+            action_name = "on poll"
+
+            # Use OnPollParams for on_poll actions
+            validated_params_class = OnPollParams
+            logger = self.__logger
+
+            @action_protocol
+            @wraps(function)
+            def inner(
+                params: OnPollParams,
+                client: SOARClient = self.actions_provider.soar_client,
+                *args: Any,  # noqa: ANN401
+                **kwargs: Any,  # noqa: ANN401
+            ) -> bool:
+                try:
+                    # Validate poll params
+                    try:
+                        action_params = validated_params_class.parse_obj(params)
+                    except Exception as e:
+                        logger.info(f"Parameter validation error: {e!s}")
+                        return self._adapt_action_result(
+                            ActionResult(
+                                status=False, message=f"Invalid parameters: {e!s}"
+                            ),
+                            client,
+                        )
+
+                    kwargs = self._build_magic_args(function, client=client, **kwargs)
+
+                    result = function(action_params, *args, **kwargs)
+
+                    # Check if container_id is provided in params
+                    container_id = getattr(params, "container_id", None)
+                    container_created = False
+
+                    for item in result:
+                        # Check if the item is a Container
+                        if isinstance(item, Container):
+                            # TODO: Remove ignores
+                            container = item.to_dict()  # Convert for saving
+                            ret_val, message, cid = client.save_container(container)  # type: ignore[attr-defined]
+                            logger.info(f"Creating container: {container['name']}")
+
+                            if ret_val:
+                                container_id = cid
+                                container_created = True
+                                item.container_id = container_id
+
+                            if (
+                                "duplicate container found" in message.lower()
+                            ):  # pragma: no cover
+                                logger.info(  # pragma: no cover
+                                    "Duplicate container found, reusing existing container"
+                                )
path: src/soar_sdk/app.py
user: login: grokas-splunk
body: Yup, added in the comment and removed and extra pragma 👍
created_at: 2025-05-27T18:23:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -296,6 +300,172 @@ def inner(
 
         return test_con_function
 
+    def on_poll(self) -> Callable[[Callable], Action]:
+        """
+        Decorator for the on_poll action.
+
+        The decorated function must be a generator (using yield) or return an Iterator that yields Container and/or Artifact objects. Only one on_poll action is allowed per app.
+
+        Usage:
+        If a Container is yielded first, all subsequent Artifacts will be added to that container unless they already have a `container_id`.
+        If an `Artifact` is yielded without a container and no `container_id` is set, it will be skipped.
+        """
+
+        def on_poll_decorator(function: Callable) -> Action:
+            if self.actions_provider.get_action("on_poll"):
+                raise TypeError(
+                    "The 'on_poll' decorator can only be used once per App instance."
+                )
+
+            # Check if function is generator function or has a return type annotation of iterator
+            is_generator = inspect.isgeneratorfunction(function)
+            signature = inspect.signature(function)
+            has_iterator_return = False
+
+            # Check if the return annotation is an Iterator type
+            if (
+                signature.return_annotation != inspect.Signature.empty
+                and hasattr(signature.return_annotation, "__origin__")
+                and signature.return_annotation.__origin__ is Iterator
+            ):
+                has_iterator_return = True
+
+            if not (is_generator or has_iterator_return):
+                raise TypeError(
+                    "The on_poll function must be a generator (use 'yield') or return an Iterator."
+                )
+
+            action_identifier = "on_poll"
+            action_name = "on poll"
+
+            # Use OnPollParams for on_poll actions
+            validated_params_class = OnPollParams
+            logger = self.__logger
+
+            @action_protocol
+            @wraps(function)
+            def inner(
+                params: OnPollParams,
+                client: SOARClient = self.actions_provider.soar_client,
+                *args: Any,  # noqa: ANN401
+                **kwargs: Any,  # noqa: ANN401
+            ) -> bool:
+                try:
+                    # Validate poll params
+                    try:
+                        action_params = validated_params_class.parse_obj(params)
+                    except Exception as e:
+                        logger.info(f"Parameter validation error: {e!s}")
+                        return self._adapt_action_result(
+                            ActionResult(
+                                status=False, message=f"Invalid parameters: {e!s}"
+                            ),
+                            client,
+                        )
+
+                    kwargs = self._build_magic_args(function, client=client, **kwargs)
+
+                    result = function(action_params, *args, **kwargs)
+
+                    # Check if container_id is provided in params
+                    container_id = getattr(params, "container_id", None)
+                    container_created = False
+
+                    for item in result:
+                        # Check if the item is a Container
+                        if isinstance(item, Container):
+                            # TODO: Remove ignores
+                            container = item.to_dict()  # Convert for saving
+                            ret_val, message, cid = client.save_container(container)  # type: ignore[attr-defined]
path: src/soar_sdk/app.py
user: login: grokas-splunk
body: So the abstract client still does not have `save_container` but we'll want to change to incorporate the new `container.create()` functionality. But I think we should make a separate ticket for that as that will have a number of changes, I updated the comment though
created_at: 2025-05-27T19:55:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -296,6 +300,172 @@ def inner(
 
         return test_con_function
 
+    def on_poll(self) -> Callable[[Callable], Action]:
+        """
+        Decorator for the on_poll action.
+
+        The decorated function must be a generator (using yield) or return an Iterator that yields Container and/or Artifact objects. Only one on_poll action is allowed per app.
+
+        Usage:
+        If a Container is yielded first, all subsequent Artifacts will be added to that container unless they already have a `container_id`.
+        If an `Artifact` is yielded without a container and no `container_id` is set, it will be skipped.
+        """
+
+        def on_poll_decorator(function: Callable) -> Action:
+            if self.actions_provider.get_action("on_poll"):
+                raise TypeError(
+                    "The 'on_poll' decorator can only be used once per App instance."
+                )
+
+            # Check if function is generator function or has a return type annotation of iterator
+            is_generator = inspect.isgeneratorfunction(function)
+            signature = inspect.signature(function)
+            has_iterator_return = False
+
+            # Check if the return annotation is an Iterator type
+            if (
+                signature.return_annotation != inspect.Signature.empty
+                and hasattr(signature.return_annotation, "__origin__")
+                and signature.return_annotation.__origin__ is Iterator
+            ):
+                has_iterator_return = True
+
+            if not (is_generator or has_iterator_return):
+                raise TypeError(
+                    "The on_poll function must be a generator (use 'yield') or return an Iterator."
+                )
+
+            action_identifier = "on_poll"
+            action_name = "on poll"
+
+            # Use OnPollParams for on_poll actions
+            validated_params_class = OnPollParams
+            logger = self.__logger
+
+            @action_protocol
+            @wraps(function)
+            def inner(
+                params: OnPollParams,
+                client: SOARClient = self.actions_provider.soar_client,
+                *args: Any,  # noqa: ANN401
+                **kwargs: Any,  # noqa: ANN401
+            ) -> bool:
+                try:
+                    # Validate poll params
+                    try:
+                        action_params = validated_params_class.parse_obj(params)
+                    except Exception as e:
+                        logger.info(f"Parameter validation error: {e!s}")
+                        return self._adapt_action_result(
+                            ActionResult(
+                                status=False, message=f"Invalid parameters: {e!s}"
+                            ),
+                            client,
+                        )
+
+                    kwargs = self._build_magic_args(function, client=client, **kwargs)
+
+                    result = function(action_params, *args, **kwargs)
+
+                    # Check if container_id is provided in params
+                    container_id = getattr(params, "container_id", None)
+                    container_created = False
+
+                    for item in result:
+                        # Check if the item is a Container
+                        if isinstance(item, Container):
+                            # TODO: Remove ignores
+                            container = item.to_dict()  # Convert for saving
+                            ret_val, message, cid = client.save_container(container)  # type: ignore[attr-defined]
path: src/soar_sdk/app.py
user: login: phantom-jacob
body: Yeah let's make a ticket for that. This is an exception waiting to happen
created_at: 2025-05-27T19:57:55Z
author_association: COLLABORATOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-05-20T16:47:55Z
body: <!-- semantic-release-breadcrumb -->
Merging this PR will release `1.0.0-beta.26` with the following release notes:

# [1.0.0-beta.26](https://github.com/phantomcyber/splunk-soar-sdk/compare/1.0.0-beta.25...1.0.0-beta.26) (2025-05-27)


### Bug Fixes

* context param bug ([f1f01ac](https://github.com/phantomcyber/splunk-soar-sdk/commit/f1f01ac44f01be248f94704feac27be055bdaa21))
* more review fixes ([cb01f99](https://github.com/phantomcyber/splunk-soar-sdk/commit/cb01f99fa85216c6ef182bbdf07788fca7f1ccb9))
* more test coverage added ([212de14](https://github.com/phantomcyber/splunk-soar-sdk/commit/212de147217dbd1dcb76a8d9b8ed047a242d03a3))
* on_poll issue fixes and other improvements ([c9cad81](https://github.com/phantomcyber/splunk-soar-sdk/commit/c9cad81e3a42e34e19486cfbfa919a9089919cd3))
* precommit applications/fixes ([418a457](https://github.com/phantomcyber/splunk-soar-sdk/commit/418a45786359a051d174a6dd46acdcf1645f7c2b))
* review changes and updates ([b89b5ce](https://github.com/phantomcyber/splunk-soar-sdk/commit/b89b5ceaa107f0a44075429e992b93bfc6cdcd6d))


### Features

* base on_poll sdk implementation ([0f2a46e](https://github.com/phantomcyber/splunk-soar-sdk/commit/0f2a46e245942dec0ff75efb4081d7e68b5a0047))
author_association: NONE
user: login: soar-sdk-semantic-release[bot]
created_at: 2025-05-27T20:31:00Z
body: :tada: This PR is included in version 1.0.0-beta.26 :tada:

The release is available on [GitHub release](https://github.com/phantomcyber/splunk-soar-sdk/releases/tag/1.0.0-beta.26)

Your **[semantic-release](https://github.com/semantic-release/semantic-release)** bot :package::rocket:
author_association: NONE