Most IoT platforms handle ingress or processing or delivery. Tendrl does all three: Contact for data ingestion and validation, Strand for workflow automation with AI, and Surface for security scanning. This guide walks through two workflows where data flows from a device, through the full platform, and back to the device.
Both workflows follow the same pattern:
- Device → Contact: publish a message with tags. Validation rules check the data at ingress.
- Contact → Strand: matching tags trigger a workflow. Surface scans the inbound payload.
- Strand (AI): Claude processes with conversation memory, context, and optionally MCP tools.
- Strand → Surface: outbound content scanned before delivery.
- Strand → Contact → Device: result sent back to the device entity, state table updated.
Validation failures don't just drop the message; they trigger separate workflows that notify ops and command the device to self-diagnose.
AI node capabilities you'll use
Before diving into the workflows, here's a quick primer on the three Strand AI node features that make these loops work:
Conversation memory
Each AI node gets Redis-backed memory, automatically keyed by { account_id }:{ node_id }. Configure memory_ttl (how long history lives) and memory_max_messages (how many messages to keep). On every run, the model loads its history before processing and saves the updated conversation after. A 2-hour TTL with 50 messages gives the AI a rolling window of recent interactions, enough to spot trends without unbounded growth.
Context and templated prompts
The context_md field sets the system prompt: the AI's role, output format, constraints. The prompt field uses Jinja2 templates to inject live data from the workflow: {{ payload.temperature }}, {{ steps.surface_scan.output_payload.threat_level }}. Combined with memory, the AI gets domain expertise (context) + live data (template) + history (memory).
MCP tool use
The mcp_servers field attaches MCP server connectors. The AI model discovers available tools automatically and decides when to call them mid-conversation. An AI node connected to a device database MCP server can look up specs, maintenance history, or threshold configs, then use that information in its response. Tool calls are traced in the output metadata.
Workflow 1: Secure AI Agent for Devices
Devices send natural language prompts to an AI assistant. Every prompt is validated, scanned for injection, processed with memory and tools, scanned again for data leaks, and delivered back to the device.
Set up the Contact entity
Create an entity for each device (or device type) and attach a service with a DynamicAction for input validation:
{
"name": "ai_request_check",
"requiredFields": ["prompt", "device_id"],
"rules": [],
"tags": ["ai-agent"]
} The requiredFields ensure every message has a prompt and device_id. When validation passes, the ai-agent tag triggers the Strand workflow. When it fails, the failure tags trigger an error workflow.
Build the Strand workflow
The workflow has 6 nodes connected in sequence with a branch after the if/else:
Node 1: Contact trigger. The workflow triggers on messages with tag ai-agent. The full message payload is available as payload.
Node 2: Surface scan (inbound). A surface.platform connector scans the prompt for injection attacks, code extraction, and sensitive data. Uses the Agentic profile (strict sensitive data detection, IP blocking). No API key needed; Strand uses an internal token.
Node 3: If/else on threat level
{{ steps.surface_scan.output_payload.threat_level == "clean" }} True branch continues to Claude. False branch sends a rejection back to the device via Contact with the threat details.
Node 4: Claude AI node. This is where all three capabilities come together:
{
"model": "claude-sonnet-4-20250514",
"context_md": "You are a diagnostic assistant for industrial devices. Provide actionable recommendations. Never include credentials or internal URLs in responses.",
"prompt": "Device: {{ payload.device_id }}\\nReadings: {{ payload.context }}\\n\\n{{ payload.prompt }}",
"memory_enabled": true,
"memory_ttl": 7200,
"memory_max_messages": 50,
"mcp_servers": ["device-db-server"],
"temperature": 0.3,
"max_tokens": 1000
} The AI sees the system prompt (context_md), the device data injected via Jinja2, its conversation history from Redis, and has access to a device database MCP server for looking up specs and maintenance logs.
Node 5: Surface scan (outbound). Scans Claude's response for credential leaks and PII before delivery.
Node 6: Contact deliver. Sends the AI response back to the device entity via connector.contact_platform with operation message, and patches the state table with update_state.
The error path
When validation fails (missing prompt or device_id), Contact stores the message, injects failure tags, and triggers a separate Strand workflow. That workflow sends the ActionResult back to the device, including which field failed, the actual value, and what was expected. The device SDK can parse this and retry with corrected data.
Device code
client.publish(
data={
"prompt": "Diagnose high vibration on motor 3",
"device_id": "motor-ctrl-07",
"context": {"rpm": 3450, "temp_c": 78.2}
},
tags=["ai-agent"]
) Workflow 2: IoT Anomaly Detection
Sensor readings are validated at ingress, analyzed by AI with conversation memory for contextual anomaly detection, and alerts are scanned by Surface before reaching dashboards. Invalid data triggers a maintenance workflow.
Validation rules
Attach a service to your sensor entity with a DynamicAction that enforces physical ranges:
{
"name": "range_check",
"requiredFields": ["temperature", "humidity", "device_id"],
"rules": [
{"field": "temperature", "operator": "between", "value": [-40, 150]},
{"field": "humidity", "operator": "between", "value": [0, 100]}
],
"tags": ["reading-valid"]
} Readings outside −40 to 150 °C or 0 to 100 % humidity are caught before they reach the AI. The between operator handles this in a single rule. Validation is passive: the message is always stored, but failed tags route to the maintenance workflow instead of the anomaly detector.
Contextual AI with conversation memory
The AI node is configured with a 2-hour TTL and space for 50 messages. On every sensor reading, the model sees its history of recent readings, a rolling window that captures trends and baseline behavior.
You are an HVAC anomaly detector. You receive sensor
readings over time and maintain memory of recent history.
Analyze the latest reading in context of recent readings.
Flag anomalies: sudden spikes, gradual drifts, unusual
correlations between temperature and humidity.
Return JSON:
{"is_anomaly": bool, "confidence": 0-1,
"severity": "info|warning|critical",
"explanation": "..."} Device: {{ payload.device_id }}
Temperature: {{ payload.temperature }}°C
Humidity: {{ payload.humidity }}% With 50 messages of history, the model can detect that temperature has been climbing 0.5 degrees per reading for the last hour, or that humidity dropped sharply while temperature stayed flat, patterns that static thresholds would miss.
If the AI flags an anomaly, an if/else node routes to a Surface scan (checking the alert for data leaks), then a Contact connector sends the alert to a dashboard entity and updates the sensor's state table.
The maintenance path
When validation fails (a sensor sends −999 for temperature or omits device_id), the failure tags trigger a separate Strand workflow:
- Notify ops: a Contact connector sends the
ActionResultto the ops dashboard entity. It includes which field failed, the actual value (−999), the expected range (−40 to 150), and the rule name. - Update device state: a Contact connector with operation
update_statesets the sensor's state toneeds_maintenancewith error details. - Send a tagged diagnostic message: a Contact connector sends a message back to the sensor with tag
diagnostic. The device routes it with@client.on(tag="diagnostic").
Bad data isn't silently dropped. It's stored, tagged, routed, and acted on.
Device code
client.publish(
data={
"temperature": 74.2,
"humidity": 45,
"device_id": "hvac-unit-12"
},
tags=["sensor-reading"]
) @client.on(tag="diagnostic")
def run_diagnostics(msg):
client.publish(self_test(), tags=["diagnostic-result"])
@client.on(tag="ai-response")
def handle_ai_reply(msg):
print(msg.get("data", {}).get("response")) Patterns across both workflows
- Passive validation: messages always store. Tags route pass and fail to different Strand workflows. You never lose data.
- Dual Surface scanning: inbound content scanned for injection and malware, outbound content scanned for data leaks. Both under 10 ms for text payloads.
- Tag-based routing: Contact tags match Strand workflows (case-insensitive, requires all tags). Pass and fail paths are just different tag sets; no if/else at the Contact level.
- ForwardValidation: enable it to send the full
ActionResult(field, actual, expected, message) to workflows for rich device feedback. - Loop prevention:
strandTriggerDepthmax 3 prevents Contact → Strand → Contact cycles from running away. - Conversation memory: configurable rolling window for contextual AI. Keyed per node, auto-trimmed, with TTL from 1 minute to 30 days.
- MCP tool use: AI nodes that can look things up mid-conversation, not just reason from training data.
What's next
- Combine workflows: anomaly detection flags a reading, then triggers the AI agent for deeper diagnosis with MCP tool lookups
- Build the whole loop from Claude: Contact, Strand and Surface each expose an MCP server, and tendrl-dev-mcp puts the board itself on the same table, so the entity, the validation rules, both workflows above and the device that feeds them can be created from one conversation. That is how the station camera was built.
- Secure AI Agent use case: visual architecture and feature breakdown
- IoT Anomaly Detection use case: validation rules, fail paths, and feature cards
Tendrl