This guide covers hub-based messaging between twins using Events and Actions. For real-time streaming and low-latency communication, see Real-Time Channels.
What Is Twin Messaging?
Twin messaging lets your apps communicate seamlessly โ whether they're running on the same device or across different locations. PhyStack intelligently routes messages locally within a device when possible, or through the cloud hub when apps are on different devices. Your code stays the same either way.
Every message has:
- A type (string) - identifies what kind of message this is
- A payload (object) - the data being sent
There are two patterns:
| Pattern | Description | Use Case |
|---|---|---|
| Events | Fire-and-forget messages | Status updates, notifications, telemetry |
| Actions | Request-response with callback | Commands that need confirmation, device control |
Getting Started
Twin messaging uses the @phystack/hub-client (TypeScript) or phystack-hub-client (Python) package. Your app connects to PhyHub automatically โ either through PhyOS on a real device, or through the local simulator during development.
import { connectPhyClient } from '@phystack/hub-client';
const client = await connectPhyClient();
const instance = await client.getInstance();
// Now you can send and receive messages
instance.on('myEvent', (data) => {
console.log('Received:', data);
});Twin IDs Explained
To send a targeted message, you need the recipient's twin ID (sometimes also called instance ID). Where this comes from depends on the context. You'll see twin IDs used throughout the examples later in this guide.
- Simulator (local dev): Start the simulator with
phy simulator start, then run your app withyarn dev. The simulator assigns each app a twin ID visible in the logs:Copy twin IDs from the simulator output and use them to populate your app's local settings values.Instance connected: my-org.my-app (fb1f8cc4-73d0-490a-82dc-7a917cbed2e2) โ 1 active - Production: Twin IDs come from app settings configured in the PhyStack console. Use instance picker fields in your settings schema to let users select which twin to communicate with.
In both cases, your app typically reads twin IDs from settings at runtime:
const { targetTwinId } = settings;
instance.to(targetTwinId).emit('hello', { message: 'Hi!' });Events vs Actions
Events (Fire-and-Forget)
Events are simple messages that don't expect a response. Use events when you just need to notify other twins about something.
// Broadcast to all subscribers
instance.emit('statusUpdate', { status: 'ready', temperature: 25 });
// Send to a specific twin
instance.to(targetTwinId).emit('statusUpdate', { status: 'ready' });
// Receiving an event
instance.on('statusUpdate', (data) => {
console.log('Status:', data.status);
});Actions (Request-Response)
Actions are messages that expect a response. Use actions when you need confirmation that something completed successfully. Actions require targeting a specific twin using to().
// Sending an action โ to().emit() with a callback triggers request-response
instance.to(targetTwinId).emit('runDiagnostics', { level: 'full' }, (result) => {
console.log('Diagnostics result:', result.status, result.message);
});
// Handling an action and responding
instance.on('runDiagnostics', (data, respond) => {
const passed = runTests(data.level);
respond({
status: passed ? 'success' : 'error',
message: passed ? 'All tests passed' : 'Tests failed',
});
});The response follows the TwinMessageResult structure:
interface TwinMessageResult {
status: 'success' | 'error' | 'warning';
message?: string;
}Sending Events
Broadcast Events
Send events to all subscribers of your twin:
instance.emit('statusUpdate', { status: 'ready' });
instance.emit('sensorData', { temperature: 25.5, humidity: 60 });Targeted Events
Send events to a specific twin:
instance.to(targetTwinId).emit('displayMessage', {
title: 'System Alert',
content: 'Maintenance scheduled for 10:00 PM',
});Receiving Events
Register handlers to receive events:
instance.on('command', (data) => {
console.log('Received command:', data);
});
instance.on('sensorReading', handleSensorData);
instance.on('configUpdate', handleConfigChange);Sending Actions
Actions wait for a response from the handler. Pass a callback as the third argument to to().emit():
instance.to(targetTwinId).emit('calibrateSensor', {
sensorId: 'temp-001',
targetValue: 25.0,
}, (result) => {
console.log('Calibration result:', result.status, result.message);
});Actions have a 10-second timeout. If no response is received, the callback receives a timeout error.
Note: Actions require a target twin. Use
instance.to(targetId).emit()for request-response patterns. The broadcastinstance.emit()is fire-and-forget only.
Handling Actions
When handling actions, you receive a respond function to send back results:
instance.on('restartService', (data, respond) => {
const success = serviceManager.restart(data.serviceName);
respond({
status: success ? 'success' : 'error',
message: success ? `Service ${data.serviceName} restarted` : 'Restart failed',
});
});Common Patterns
Edge to Screen Communication
Edge app sends data to screen for display. For example, a barcode scanner edge app reads a product barcode and pushes the product details to a digital signage screen for the customer to see.
// Edge app โ scan barcode and send product to screen
const product = await lookupProduct(barcode);
instance.to(screenTwinId).emit('showProduct', {
name: product.name,
price: product.price,
image: product.imageUrl,
});
// Screen app โ display product to customer
instance.on('showProduct', (data) => {
displayProductCard(data.name, data.price, data.image);
});Screen to Edge with Response
Screen requests an action from edge, edge confirms. For example, a self-checkout kiosk screen sends a print command to the edge app controlling a thermal receipt printer, and waits for confirmation before showing "Receipt printed" to the customer.
// Screen app โ request action
instance.to(edgeTwinId).emit('print', { text: 'Receipt #12345' }, (result) => {
console.log('Print result:', result.status, result.message);
});
// Edge app โ handle and respond
instance.on('print', (data, respond) => {
printer.print(data.text);
respond({ status: 'success', message: 'Printed successfully' });
});Peripheral Communication
Edge apps create peripheral twins to represent connected hardware (printers, sensors, scanners). Other apps discover and communicate with peripherals. For example, an edge app managing a temperature sensor creates a peripheral twin and broadcasts readings โ a screen app subscribes and displays live data on a dashboard. For a full guide, see Peripherals and Descriptors.
// Edge app โ create peripheral and listen for commands
const peripheral = await instance.createPeripheralTwin(
'temperature-sensor',
'sensor-001',
{ model: 'TMP36', location: 'warehouse' },
);
const sensorInstance = await client.getPeripheralInstance(peripheral.id);
sensorInstance.on('read', (data, respond) => {
respond({ status: 'success', message: 'Temperature: 25.5C' });
});
// Periodically emit readings from the peripheral
setInterval(() => {
sensorInstance.emit('sensorData', { temperature: 25.5, humidity: 60 });
}, 5000);// Screen app โ subscribe to peripheral data
const sensorInst = await client.getPeripheralInstance(settings.peripheralTwinId);
sensorInst.on('sensorData', (data) => {
console.log('Temperature:', data.temperature);
});Checkpoint
You should now understand:
- The difference between Events and Actions
- When to use
emit()vsto().emit() - How to send and receive messages between twins
- Where twin IDs come from (simulator vs production)
- Common communication patterns (edge-screen, peripherals)
Next Steps
- Communication Overview - Compare messaging approaches
- Real-Time Channels - WebRTC DataChannels and MediaStreams
- Peripherals and Descriptors - Hardware integration
- Settings Schemas - Configure twin IDs and app settings