Execution and Lifecycle
This page covers the callbacks a node can override to participate in the workflow lifecycle, and the patterns for long-running asynchronous work such as external API integration with polling.
Lifecycle Callbacks
All callbacks are overridable:
- allow_incoming_connection, allow_outgoing_connection: Return bool for connection validation
- after_incoming_connection, after_outgoing_connection: Handle post-connection logic
- after_incoming_connection_removed, after_outgoing_connection_removed: Handle disconnection
- before_value_set: Return modified value before setting
- after_value_set: React to parameter value changes
- validate_before_workflow_run, validate_before_node_run: Return list[Exception]|None
- on_griptape_event: Handle workflow events
- initialize_spotlight: Setup spotlight functionality
- get_next_control_output: Return Parameter|None for control flow
Helper Methods
hide_parameter_by_name(),show_parameter_by_name()append_value_to_parameter()publish_update_to_parameter()show_message_by_name(),hide_message_by_name(),get_message_by_name_or_element_id()
Asynchronous API Integration
Nodes have two ways to perform long-running work without stalling the engine:
- Override
async def aprocess()(preferred). The engine awaits it on its event loop, so genuinely asynchronous integrations (async HTTP clients,await asyncio.sleep()polling) run concurrently with the rest of the engine. - Override
process()andyieldcallables (AsyncResult). Each yielded callable is executed synchronously on a background thread — the engine stays responsive, but the work itself is still blocking, sequential code.
If you are writing a new integration, reach for aprocess() and async I/O. Use the yield pattern when your integration is built on synchronous libraries (requests, blocking SDKs) that you don't want to rewrite.
Async Processing with aprocess() (Preferred)
import asyncio
import httpx
from griptape_nodes.exe_types.node_types import ControlNode
POLLING_INTERVAL = 10 # seconds (use API-recommended value)
MAX_POLLING_ATTEMPTS = 60 # 10 minutes max
class MyAsyncNode(ControlNode):
async def aprocess(self) -> None:
"""Process the request asynchronously."""
try:
# Set safe defaults
self._set_safe_defaults()
# Validate API key
api_key = self._validate_api_key()
async with httpx.AsyncClient(timeout=60) as client:
# Submit task
task_id = await self._submit_task(client, api_key)
# Poll for completion
result = await self._poll_for_completion(client, task_id, api_key)
# Process result
self.parameter_output_values["output"] = result
except Exception as e:
self._set_safe_defaults()
self._log(f"Processing failed: {e}")
raise RuntimeError(f"{self.name}: {e}") from e
async def _submit_task(self, client: httpx.AsyncClient, api_key: str) -> str:
response = await client.post(
"https://api.example.com/v1/tasks",
json=self._build_payload(),
headers={"Authorization": f"Bearer {api_key}"},
)
response.raise_for_status()
return response.json()["task_id"]
async def _poll_for_completion(self, client: httpx.AsyncClient, task_id: str, api_key: str) -> str:
for attempt in range(MAX_POLLING_ATTEMPTS):
await asyncio.sleep(POLLING_INTERVAL) # never time.sleep() in aprocess
response = await client.get(
"https://api.example.com/v1/query/task",
params={"task_id": task_id},
headers={"Authorization": f"Bearer {api_key}"},
)
response.raise_for_status()
status_data = response.json()
if status_data["status"] == "Success":
return status_data["result"]
if status_data["status"] == "Fail":
error_msg = status_data.get("error_message", "Unknown error")
raise RuntimeError(f"Task failed: {error_msg}")
# Continue polling for "Processing", "Pending", etc.
raise RuntimeError(f"Task did not complete within {MAX_POLLING_ATTEMPTS * POLLING_INTERVAL} seconds")
Key Points:
- Override
async def aprocess()instead ofprocess()— the engine awaits it directly - Use async I/O throughout:
httpx.AsyncClientfor requests,await asyncio.sleep()for polling delays - Blocking calls (
requests,time.sleep()) insideaprocess()stall the engine's event loop — if you must call a blocking function, wrap it withawait asyncio.to_thread(blocking_fn) - The base class's default
aprocess()wrapsprocess(), so nodes only need to override one of the two
Blocking Work on a Background Thread (process() + yield)
For integrations built on synchronous libraries, override process() and yield a callable. The engine runs each yielded callable synchronously on a background thread and resumes the generator with its return value — the engine stays responsive, but this does not make the work itself asynchronous:
from griptape_nodes.exe_types.node_types import ControlNode, AsyncResult
class MyBlockingNode(ControlNode):
def process(self) -> AsyncResult | None:
"""Yield the blocking work to a background thread."""
yield lambda: self._process()
def _process(self) -> None:
"""Main processing method (runs synchronously on a background thread)."""
try:
# Set safe defaults
self._set_safe_defaults()
# Validate API key
api_key = self._validate_api_key()
# Submit task
task_id = self._submit_task(api_key)
# Poll for completion
result = self._poll_for_completion(task_id, api_key)
# Process result
self.parameter_output_values["output"] = result
except Exception as e:
self._set_safe_defaults()
self._log(f"Processing failed: {e}")
raise RuntimeError(f"{self.name}: {str(e)}") from e
Key Points:
process()returnsAsyncResult | Noneand yields a callable- Each yielded callable runs synchronously on a background thread; the generator resumes with its return value
- Fine for existing synchronous code (
requests, blocking SDKs), but preferaprocess()for new integrations
Polling Pattern for Long-Running Tasks
When integrating with APIs that use asynchronous task processing (video generation, model training, etc.), implement a three-step pattern. The examples below use synchronous requests calls, suited to the background-thread pattern above; in aprocess(), use httpx.AsyncClient and await asyncio.sleep() instead, as shown earlier.
Step 1: Task Submission
def _submit_task(self, params: dict[str, Any], headers: dict[str, str]) -> dict[str, Any]:
"""Submit task and return response with task_id."""
payload = self._build_payload(params)
response = requests.post(
self.API_BASE_URL,
json=payload,
headers=headers,
timeout=DEFAULT_TIMEOUT
)
response.raise_for_status()
response_data = response.json()
task_id = response_data.get("task_id")
return response_data
Step 2: Status Polling
POLLING_INTERVAL = 10 # seconds (use API-recommended value)
MAX_POLLING_ATTEMPTS = 60 # 10 minutes max
def _poll_for_completion(self, task_id: str, headers: dict[str, str]) -> str | None:
"""Poll API for task completion and return result identifier."""
query_url = "https://api.example.com/v1/query/task"
for attempt in range(MAX_POLLING_ATTEMPTS):
time.sleep(POLLING_INTERVAL) # Wait before each poll
response = requests.get(
query_url,
headers=headers,
params={"task_id": task_id}, # Use query params, not path
timeout=DEFAULT_TIMEOUT
)
response.raise_for_status()
status_data = response.json()
status = status_data.get("status")
self._log(f"Polling attempt {attempt + 1}: Status = {status}")
if status == "Success":
file_id = status_data.get("file_id")
return file_id
elif status == "Fail":
error_msg = status_data.get("error_message", "Unknown error")
raise RuntimeError(f"Task failed: {error_msg}")
# Continue polling for "Processing", "Pending", etc.
raise RuntimeError(f"Task did not complete within {MAX_POLLING_ATTEMPTS * POLLING_INTERVAL} seconds")
Step 3: Result Retrieval
def _retrieve_result(self, file_id: str, headers: dict[str, str]) -> str:
"""Retrieve download URL from result identifier."""
retrieve_url = "https://api.example.com/v1/files/retrieve"
response = requests.get(
retrieve_url,
headers=headers,
params={"file_id": file_id},
timeout=DEFAULT_TIMEOUT
)
response.raise_for_status()
response_data = response.json()
download_url = response_data.get("file", {}).get("download_url")
return download_url
Key Considerations:
- Always use API-recommended polling intervals (typically 5-10 seconds)
- Set reasonable maximum attempts to prevent infinite loops
- Use query parameters, not path parameters, for task_id (verify with API docs)
- Handle all status states: Success, Fail, Processing, Pending
- Log polling attempts for debugging
- Set safe defaults on failure
Dynamic Endpoint Selection Based on Inputs
When a node can operate in multiple modes depending on which inputs are connected (e.g., image-to-video when images are provided, text-to-video when they are not), select the API endpoint dynamically in the process method rather than hardcoding a single URL:
IMAGE2VIDEO_URL = "https://api.example.com/v1/videos/image2video"
TEXT2VIDEO_URL = "https://api.example.com/v1/videos/text2video"
def _process(self):
image_data = self._get_image_data("start_frame")
has_images = image_data is not None
if has_images:
api_url = IMAGE2VIDEO_URL
else:
api_url = TEXT2VIDEO_URL
payload = self._build_payload()
if image_data:
payload["image"] = image_data
response = requests.post(api_url, headers=headers, json=payload, timeout=30)
# ... polling uses the same api_url for status checks
poll_url = f"{api_url}/{task_id}"
This avoids requiring image inputs when the user wants text-only generation, and ensures the correct API endpoint is called for each mode. The polling URL should use the same base endpoint.
Image Artifact Conversion to Base64
CRITICAL: Localhost URL Handling
When sending images to external APIs, ImageUrlArtifact URLs from static storage are localhost and inaccessible to external services. Always detect and convert localhost URLs to base64:
import base64
def _get_image_data(self, image_artifact: ImageArtifact | ImageUrlArtifact) -> str:
"""Convert image artifact to URL or base64 data URI."""
# ImageUrlArtifact - check if localhost or public URL
if isinstance(image_artifact, ImageUrlArtifact):
url = image_artifact.value
# Localhost URLs must be converted to base64 for external APIs
if url.startswith(('http://localhost', 'http://127.0.0.1',
'https://localhost', 'https://127.0.0.1')):
self._log(f"Converting localhost URL to base64: {url[:100]}...")
response = requests.get(url, timeout=30)
response.raise_for_status()
image_bytes = response.content
# Detect MIME type from headers
mime_type = response.headers.get('content-type', 'image/jpeg')
if not mime_type.startswith('image/'):
mime_type = 'image/jpeg'
base64_data = base64.b64encode(image_bytes).decode('utf-8')
return f"data:{mime_type};base64,{base64_data}"
# Public URLs can be passed through
self._log(f"Using public URL: {url[:100]}...")
return url
# ImageArtifact - use .base64 property (preferred method)
if isinstance(image_artifact, ImageArtifact):
# PREFERRED: Use built-in properties
if hasattr(image_artifact, 'base64') and hasattr(image_artifact, 'mime_type'):
base64_data = image_artifact.base64 # Raw base64 (no prefix)
mime_type = image_artifact.mime_type # e.g., 'image/jpeg'
# Check if already has data URI prefix
if base64_data.startswith('data:'):
self._log("Using ImageArtifact.base64 (already has data URI)")
return base64_data
# Add data URI prefix
self._log(f"Using ImageArtifact.base64 with mime_type: {mime_type}")
return f"data:{mime_type};base64,{base64_data}"
# FALLBACK: Manual byte extraction
self._log("Falling back to manual base64 encoding")
if hasattr(image_artifact, 'value') and hasattr(image_artifact.value, 'read'):
image_artifact.value.seek(0)
image_bytes = image_artifact.value.read()
elif hasattr(image_artifact, 'data'):
if isinstance(image_artifact.data, bytes):
image_bytes = image_artifact.data
elif hasattr(image_artifact.data, 'read'):
image_artifact.data.seek(0)
image_bytes = image_artifact.data.read()
else:
raise ValueError("Unsupported ImageArtifact format")
else:
raise ValueError("Unsupported ImageArtifact format")
# Detect MIME type with PIL
mime_type = "image/jpeg"
try:
from PIL import Image
from io import BytesIO
img = Image.open(BytesIO(image_bytes))
format_to_mime = {
'JPEG': 'image/jpeg',
'PNG': 'image/png',
'WEBP': 'image/webp'
}
mime_type = format_to_mime.get(img.format, 'image/jpeg')
except Exception:
pass
base64_data = base64.b64encode(image_bytes).decode('utf-8')
return f"data:{mime_type};base64,{base64_data}"
raise ValueError("Unsupported artifact type")
Key Points:
- Always detect localhost URLs - External APIs cannot access them
- Use ImageArtifact.base64 property - The proper Griptape way (returns raw base64)
- Use ImageArtifact.mime_type property - Automatic MIME type detection
- Log which path is used - Essential for debugging
- Download localhost files - Convert to base64 before sending to API
Parameter Definition:
Parameter(
name="image_input",
input_types=["ImageArtifact", "ImageUrlArtifact"], # Accept both
type="ImageArtifact",
tooltip="Image input (file or URL)",
ui_options={"clickable_file_browser": True}, # Enable file browser
)
Multi-Image Input Validation
When nodes accept multiple image parameters, use a reusable validation method with clear parameter identification:
def _validate_image(self, image_artifact: ImageArtifact | ImageUrlArtifact,
param_name: str) -> list[Exception]:
"""Validate image with parameter name in error messages."""
exceptions = []
if isinstance(image_artifact, ImageArtifact):
# Get image bytes
if hasattr(image_artifact, 'value') and hasattr(image_artifact.value, 'read'):
image_artifact.value.seek(0)
image_bytes = image_artifact.value.read()
image_artifact.value.seek(0)
else:
return exceptions
# Validate size
size_mb = len(image_bytes) / (1024 * 1024)
if size_mb >= 20:
exceptions.append(ValueError(
f"{self.name}: {param_name} size must be < 20MB (current: {size_mb:.1f}MB)"
))
# Validate format and dimensions
try:
from PIL import Image
from io import BytesIO
img = Image.open(BytesIO(image_bytes))
if img.format not in ['JPEG', 'PNG', 'WEBP']:
exceptions.append(ValueError(
f"{self.name}: {param_name} format must be JPG, PNG, or WebP (current: {img.format})"
))
width, height = img.size
short_edge = min(width, height)
if short_edge <= 300:
exceptions.append(ValueError(
f"{self.name}: {param_name} short edge must be > 300px (current: {short_edge}px)"
))
except ImportError:
self._log("PIL not available for validation")
except Exception as e:
self._log(f"Error validating {param_name}: {e}")
return exceptions
def validate_before_node_run(self) -> list[Exception] | None:
"""Validate all image parameters."""
exceptions = []
# Validate each image parameter independently
first_frame = self.get_parameter_value("first_frame_image")
if first_frame:
exceptions.extend(self._validate_image(first_frame, "first_frame_image"))
last_frame = self.get_parameter_value("last_frame_image")
if last_frame:
exceptions.extend(self._validate_image(last_frame, "last_frame_image"))
return exceptions if exceptions else None
Benefits:
- Clear error messages identifying which image parameter has issues
- Reusable validation logic across multiple image inputs
- Independent validation for each parameter
- Actionable feedback for users
Model-Dependent Parameter Management
When different models support different parameter combinations:
def after_value_set(self, parameter: Parameter, value: Any) -> None:
"""Handle model-dependent parameter visibility and options."""
if parameter.name == "model":
if value == "AdvancedModel":
# Show model-specific parameters
self.show_parameter_by_name("advanced_option")
# Update dropdown choices dynamically
resolution_param = self.get_parameter_by_name("resolution")
if resolution_param:
for child in resolution_param.children:
if hasattr(child, 'choices'):
child.choices = ADVANCED_MODEL_RESOLUTIONS
break
else:
# Hide and reset for other models
self.hide_parameter_by_name("advanced_option")
# Update to standard choices
resolution_param = self.get_parameter_by_name("resolution")
if resolution_param:
for child in resolution_param.children:
if hasattr(child, 'choices'):
child.choices = STANDARD_RESOLUTIONS
break
self.set_parameter_value("resolution", "720P")
return super().after_value_set(parameter, value)
Model-Specific Validation:
def validate_before_node_run(self) -> list[Exception] | None:
"""Validate model-specific parameter combinations."""
exceptions = []
model = self.get_parameter_value("model")
duration = self.get_parameter_value("duration")
resolution = self.get_parameter_value("resolution")
# Example: 10s only for specific model/resolution
if duration == 10:
if model != "AdvancedModel":
exceptions.append(ValueError(f"{self.name}: 10s duration only supported by AdvancedModel"))
elif resolution == "4K":
exceptions.append(ValueError(f"{self.name}: 10s duration not supported with 4K resolution"))
# Model-specific parameter requirements
if model in ["ModelB", "ModelC"]:
required_param = self.get_parameter_value("required_for_model_b_c")
if not required_param:
exceptions.append(ValueError(f"{self.name}: Parameter required for {model}"))
return exceptions if exceptions else None
Deprecated Model Migration and User Notification
When a model provider deprecates endpoints (e.g., preview models replaced by GA equivalents), nodes should automatically migrate saved workflows while informing the user. This pattern uses three components working together:
- A
DEPRECATED_MODELSdictionary mapping old model names to their replacements - A hidden
ParameterMessageelement that acts as a dismissable info banner - The
before_value_setlifecycle hook to intercept and replace deprecated values before they are applied
Step 1: Define the deprecation map and current models
from griptape_nodes.exe_types.core_types import Parameter, ParameterMessage
from griptape_nodes.traits.button import Button
MODELS = [
"veo-3.1-generate-001",
"veo-3.1-fast-generate-001",
]
# Mapping of deprecated model names to their replacements.
# When a saved workflow references one of these, the node auto-migrates.
DEPRECATED_MODELS: dict[str, str] = {
"veo-3.1-generate-preview": "veo-3.1-generate-001",
"veo-3.1-fast-generate-preview": "veo-3.1-fast-generate-001",
"veo-3.0-generate-001": "veo-3.1-generate-001",
"veo-2.0-generate-001": "veo-3.1-generate-001",
}
Step 2: Add a hidden ParameterMessage in __init__
Place this after the model parameter so it appears near the model selector in the UI. The hide=True keeps it invisible until needed. The Button trait with on_click gives the user a "Dismiss" button.
def __init__(self, **kwargs):
super().__init__(**kwargs)
# ... model parameter added above ...
# Hidden deprecation notice — shown when a deprecated model is detected
self.add_node_element(
ParameterMessage(
name="model_deprecation_notice",
title="Model Deprecation Notice",
variant="info",
value="",
traits={
Button(
full_width=True,
on_click=lambda _, __: self.hide_message_by_name("model_deprecation_notice"),
)
},
button_text="Dismiss",
hide=True,
)
)
Step 3: Implement before_value_set to intercept deprecated models
before_value_set fires before the parameter's value is applied. This is the right place to swap a deprecated model for its replacement, because after_value_set (and any logic that depends on the model value) will see the replacement.
def before_value_set(self, parameter: Parameter, value: Any) -> Any:
"""Auto-migrate deprecated models and show a deprecation notice."""
if parameter.name == "model" and value in DEPRECATED_MODELS:
replacement = DEPRECATED_MODELS[value]
message = self.get_message_by_name_or_element_id("model_deprecation_notice")
if message is not None:
message.value = (
f"The '{value}' model has been deprecated. "
f"The model has been updated to '{replacement}'. "
"Please save your workflow to apply this change."
)
self.show_message_by_name("model_deprecation_notice")
value = replacement
return super().before_value_set(parameter, value)
Step 4: Hide the notice when the user selects a valid model
In after_value_set, dismiss the banner when the current model is not deprecated. This handles the case where the user manually selects a different model after the migration.
def after_value_set(self, parameter: Parameter, value: Any) -> None:
if parameter.name == "model":
# ... model-specific logic (update duration choices, etc.) ...
if value not in DEPRECATED_MODELS:
self.hide_message_by_name("model_deprecation_notice")
return super().after_value_set(parameter, value)
How it works end-to-end:
- A user opens a workflow saved with
"veo-3.1-generate-preview". - The framework calls
before_value_setwith the saved value. - The hook detects it in
DEPRECATED_MODELS, swaps it to"veo-3.1-generate-001", and shows the info banner. after_value_setfires with the replacement value — model-dependent UI updates (duration choices, parameter visibility, etc.) work correctly because they see the valid GA model.- The user sees the banner: "The 'veo-3.1-generate-preview' model has been deprecated. The model has been updated to 'veo-3.1-generate-001'. Please save your workflow to apply this change."
- The user can dismiss the banner or it hides automatically on the next valid model selection.
Key API methods used:
| Method | Purpose |
|---|---|
self.add_node_element(ParameterMessage(...)) |
Adds the message element to the node |
self.get_message_by_name_or_element_id(name) |
Retrieves the message element to update its value |
self.show_message_by_name(name) |
Makes the hidden message visible |
self.hide_message_by_name(name) |
Hides the message again |
Reference implementations:
GriptapeCloudPromptingriptape_nodes_library/config/prompt/griptape_cloud_prompt.py(standard library)VeoVideoGenerator,VeoImageToVideoGenerator,VeoTextToVideoWithRefin thegriptape-nodes-library-googleaiexternal library
Enhanced Debug Logging for API Integration
For nodes that integrate with external APIs, implement comprehensive debug logging to quickly diagnose issues:
# Task Submission - Log full response
def _submit_task(self, params: dict, headers: dict) -> dict:
response = requests.post(API_URL, json=payload, headers=headers)
response.raise_for_status()
response_data = response.json()
self._log(f"Task submission response: {json.dumps(response_data, indent=2)}")
return response_data
# Payload Sizes - Log data sizes before sending
def _log_request(self, payload: dict) -> None:
if "first_frame_image" in payload:
img_len = len(payload.get("first_frame_image", ""))
self._log(f"first_frame_image data length: {img_len} chars (~{img_len/1024:.1f}KB)")
if "last_frame_image" in payload:
img_len = len(payload.get("last_frame_image", ""))
self._log(f"last_frame_image data length: {img_len} chars (~{img_len/1024:.1f}KB)")
# Error Responses - Log full API error details
def _poll_for_completion(self, task_id: str, headers: dict) -> str:
status_data = response.json()
status = status_data.get("status")
if status == "Fail":
# Log complete error response for debugging
self._log(f"Full API error response: {json.dumps(status_data, indent=2)}")
error_msg = status_data.get("error_message", "Unknown error")
raise RuntimeError(f"Task failed: {error_msg}")
# Processing Paths - Log which code path is executed
def _get_image_data(self, image_artifact) -> str:
if isinstance(image_artifact, ImageUrlArtifact):
if url.startswith('http://localhost'):
self._log(f"Converting localhost URL to base64: {url[:100]}...")
else:
self._log(f"Using public URL: {url[:100]}...")
elif isinstance(image_artifact, ImageArtifact):
if hasattr(image_artifact, 'base64'):
self._log(f"Using ImageArtifact.base64 with mime_type: {mime_type}")
else:
self._log("Falling back to manual base64 encoding")
What to Log:
- Full API responses (submission, polling, retrieval)
- Payload sizes (especially for base64 data)
- Processing paths (which code branches execute)
- Model/parameter combinations being used
- Error details (full error response from API)
Benefits:
- Quickly identify where failures occur
- Understand what data is being sent
- Track which code paths execute
- Get exact API error messages and codes
- Debug without reproducing issues
API Documentation Verification
Critical Best Practice: Always verify API specifications directly from documentation.
Common Pitfalls to Avoid:
- Model Names: Check exact capitalization (
MiniMax-Hailuo-02notvideo-01) - Endpoints: Verify exact URLs (
/v1/query/video_generationnot/v1/video_generation/{id}) - Parameters: Check query params vs path params
- Response Structure: Verify exact field names (
file_idvsfile_list) - Polling Intervals: Use API-recommended values
Example: Correct vs Incorrect Polling:
# ✅ CORRECT: Query parameter
response = requests.get(
"https://api.example.com/v1/query/task",
params={"task_id": task_id}
)
# ❌ INCORRECT: Path parameter (unless API specifies this)
response = requests.get(
f"https://api.example.com/v1/query/task/{task_id}"
)
When Documentation is Inaccessible:
- Explicitly state inability to access web pages (e.g., JavaScript-heavy docs)
- Request user to provide relevant documentation sections
- Never assume or infer API patterns without verification
- Update implementation when code samples are provided