Skip to content

Twin Messaging

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, 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:

PatternDescriptionUse Case
EventsFire-and-forget messagesStatus updates, notifications, telemetry
ActionsRequest-response with callbackCommands 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);
});
from phystack.hub_client import connect_phy_client

client = await connect_phy_client()
instance = await client.get_instance()

# Now you can send and receive messages
def on_my_event(data, respond=None):
    print('Received:', data)

instance.on('myEvent', on_my_event)

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 with yarn dev. The simulator assigns each app a twin ID visible in the logs:
    Instance connected: my-org.my-app (fb1f8cc4-73d0-490a-82dc-7a917cbed2e2) — 1 active
    Copy twin IDs from the simulator output and use them to populate your app’s local settings values.
  • 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);
});
# Broadcast to all subscribers
instance.emit('statusUpdate', {'status': 'ready', 'temperature': 25})

# Send to a specific twin
instance.to(target_twin_id).emit('statusUpdate', {'status': 'ready'})

# Receiving an event
def on_status_update(data, respond=None):
    print('Status:', data['status'])

instance.on('statusUpdate', on_status_update)

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',
  });
});
from phystack.hub_client import TwinMessageResult, TwinMessageResultStatus

# Sending an action — to().emit() with a callback triggers request-response
def on_result(result):
    print('Diagnostics result:', result.status, result.message)

instance.to(target_twin_id).emit('runDiagnostics', {'level': 'full'}, on_result)

# Handling an action and responding
def on_run_diagnostics(data, respond=None):
    passed = run_tests(data['level'])
    if respond:
        respond(TwinMessageResult(
            status=TwinMessageResultStatus.SUCCESS if passed else TwinMessageResultStatus.ERROR,
            message='All tests passed' if passed else 'Tests failed',
        ))

instance.on('runDiagnostics', on_run_diagnostics)

The response follows the TwinMessageResult structure:

interface TwinMessageResult {
  status: 'success' | 'error' | 'warning';
  message?: string;
}
# TwinMessageResult dataclass with TwinMessageResultStatus enum
from phystack.hub_client import TwinMessageResult, TwinMessageResultStatus

result = TwinMessageResult(
    status=TwinMessageResultStatus.SUCCESS,  # or ERROR, WARNING
    message='Operation completed',
)

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 });
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',
});
instance.to(target_twin_id).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);
def on_command(data, respond=None):
    print('Received command:', data)

instance.on('command', on_command)
instance.on('sensorReading', handle_sensor_data)
instance.on('configUpdate', handle_config_change)

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);
});
def on_calibrate_result(result):
    print('Calibration result:', result.status, result.message)

instance.to(target_twin_id).emit('calibrateSensor', {
    'sensorId': 'temp-001',
    'targetValue': 25.0,
}, on_calibrate_result)

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 broadcast instance.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',
  });
});
def on_restart_service(data, respond=None):
    success = service_manager.restart(data['serviceName'])
    if respond:
        respond(TwinMessageResult(
            status=TwinMessageResultStatus.SUCCESS if success else TwinMessageResultStatus.ERROR,
            message=f"Service {data['serviceName']} restarted" if success else 'Restart failed',
        ))

instance.on('restartService', on_restart_service)

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);
});
# Edge app — scan barcode and send product to screen
product = await lookup_product(barcode)
instance.to(screen_twin_id).emit('showProduct', {
    'name': product['name'],
    'price': product['price'],
    'image': product['imageUrl'],
})

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' });
});
# Screen app — request action
instance.to(edge_twin_id).emit('print', {'text': 'Receipt #12345'}, lambda result:
    print('Print result:', result.status, result.message)
)

# Edge app — handle and respond
def on_print(data, respond=None):
    printer.print(data['text'])
    if respond:
        respond(TwinMessageResult(
            status=TwinMessageResultStatus.SUCCESS,
            message='Printed successfully',
        ))

instance.on('print', on_print)

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);
});
# Edge app — create peripheral and listen for commands
peripheral = await instance.create_peripheral_twin(
    'temperature-sensor', 'sensor-001',
    {'model': 'TMP36', 'location': 'warehouse'},
)

sensor_instance = await client.get_peripheral_instance(peripheral['id'])

def on_read(data, respond=None):
    if respond:
        respond(TwinMessageResult(
            status=TwinMessageResultStatus.SUCCESS,
            message='Temperature: 25.5C',
        ))

sensor_instance.on('read', on_read)

# Periodically emit readings from the peripheral
async def sensor_loop():
    while True:
        sensor_instance.emit('sensorData', {'temperature': 25.5, 'humidity': 60})
        await asyncio.sleep(5)

Checkpoint

You should now understand:

  • The difference between Events and Actions
  • When to use emit() vs to().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