Build PhyStack
Twin Messaging ยท Build PhyStack

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

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);
});

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);
});

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 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',
  });
});

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() 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

ยฉ 2026 ยท PhyStack. An Ombori company