Mobile SDK#
Orch8 compiles to native iOS and Android libraries via UniFFI. The same Rust engine that runs on servers executes workflows locally on mobile devices — offline-first, battery-aware, with server-side visibility and control.
No other workflow engine runs on mobile. Temporal is JVM-based. Inngest is cloud-only. Orch8 is the only orchestration engine that works on phones.
Why mobile workflows?
- ✓Field operations — Technicians following multi-step diagnostic sequences on a tablet — even without connectivity
- ✓Healthcare compliance — Patient intake workflows that must complete reliably and surface approvals to administrators
- ✓Logistics — Delivery drivers running checklists that sync status back to the dispatch dashboard
- ✓Mobile onboarding — Server-configurable user journeys that adapt in real time without app store deployments
Architecture#
Mobile workflows follow a simple principle: the device is autonomous, the server is the mailbox.
Mobile Device Server Dashboard
---------------------------------------------------------------------
[run sequences locally]
[accumulate status changes]
[hit wait_for_input]
--POST /mobile/sync----------> [store status updates]
{ status_updates, [store approval requests]
approval_requests, [return pending commands]
command_acks } [return sync_interval_hint]
<----response-----------------
{ commands, sync_interval }
... mobile sleeps ...
[admin sees workflows]
[admin approves/cancels]
[queue command]
[send silent push]
<--silent push notification---
[wake up]
--POST /mobile/sync----------> [drain commands]
<----response-----------------
{ commands: [complete_step] }
[execute command locally]
[complete_step / cancel / etc]Design principles
- —Mobile stays autonomous — Sequences execute locally. Mobile never depends on server availability to advance a workflow.
- —Server is the mailbox — Stores status, queues commands, dispatches push notifications. Never executes mobile workflows.
- —Single round-trip per sync — One HTTP request carries everything in both directions — status updates, approval requests, and command acknowledgements.
- —Push-driven wake — Mobile sleeps when idle, wakes on silent push notification. No fixed-interval polling.
- —Battery-first — Adaptive sync intervals, state coalescing, power-state multipliers. No unnecessary radio wake-ups.
Sync Protocol#
All mobile-server communication flows through a single bidirectional endpoint. The device sends status updates and approval requests; the server returns pending commands and a sync interval hint.
Request: POST /mobile/sync
{
"device_id": "device-abc-123",
"status_updates": [
{
"instance_id": "uuid",
"sequence_name": "onboarding-flow",
"state": "Waiting",
"current_step": "request_approval",
"handler": "request_approval",
"timestamp": "2026-05-19T10:30:00Z",
"context_summary": {}
}
],
"approval_requests": [
{
"instance_id": "uuid",
"block_id": "step-3",
"prompt": "Action requires approval. Please review and decide.",
"choices": [
{ "label": "Approve", "value": "approved" },
{ "label": "Reject", "value": "rejected" }
],
"store_as": "approval_result",
"timeout_seconds": 86400,
"metadata": {}
}
],
"command_acks": ["cmd-001", "cmd-002"]
}Response
{
"commands": [
{
"id": "cmd-003",
"type": "complete_step",
"instance_id": "uuid",
"step_name": "request_approval",
"output": "{\"value\":\"approved\"}"
},
{
"id": "cmd-004",
"type": "cancel_instance",
"instance_id": "uuid-2"
}
],
"sync_interval_secs": 30
}Command types
| Type | Fields | Mobile action |
|---|---|---|
complete_step | instance_id, step_name, output | engine.complete_step(...) |
cancel_instance | instance_id | engine.cancel_instance(...) |
send_signal | instance_id, signal_type, payload | engine.send_signal(...) |
Idempotency
- —Commands are keyed by
id. Mobile ACKs after execution. Server re-delivers unACKed commands on next sync. - —Approval requests are keyed by
(device_id, instance_id, block_id). Duplicate POSTs are ignored. - —Status updates are last-write-wins per
(device_id, instance_id). Latest state overwrites.
Adaptive Sync Intervals#
Mobile does not poll on a fixed timer. The sync cycle piggybacks on the engine tick loop via a tick counter. The server dynamically adjusts the interval based on pending work.
| Condition | Interval | Rationale |
|---|---|---|
| Workflows actively advancing | 15s | Keep dashboard current |
| All workflows waiting on approvals | Stop syncing | Wait for push |
| No active workflows | Stop syncing | Nothing happening |
| Push notification received | Immediate sync | Admin acted — pick up commands now |
| Heartbeat fallback | 10-15 min | Catches missed pushes |
| Low battery (PowerState) | 2x interval | Conserve battery |
| App backgrounded | 2x interval, min 60s | OS throttles network anyway |
Push Notifications#
Push notifications are a content-less "sync now" signal. They carry no commands or data. The sync endpoint is the single source of truth.
Why push as signal only
- —Push delivery is unreliable (can be dropped, delayed, reordered)
- —Push payload size is limited (4KB APNs, varies FCM)
- —Commands need ACK/retry semantics that push doesn't provide
- —Single source of truth prevents split-brain between push payload and server state
Platform specifics
| Platform | Mechanism | Key detail |
|---|---|---|
| iOS | APNs silent push | content-available: 1, no alert/badge/sound |
| Android | FCM data message | data only, no notification block |
Both wake the app in background for ~30 seconds — enough for one sync round-trip.
Host app responsibility
The engine does NOT handle push registration or receiving. The host app (Swift/Kotlin) must:
- Register for remote notifications with the OS
- Pass the push token to the engine via
register_push_token(token) - On receiving a silent push, call
engine.on_push_received() - The engine triggers an immediate sync cycle on the next tick (~200ms)
Approval Flow#
When a mobile workflow hits a wait_for_input step, it sends an approval request to the server. An admin resolves it from the dashboard, and the resolution flows back to the device via push + sync.
1. Mobile workflow reaches wait_for_input step
2. Mobile sends approval_request in next sync
3. Server stores approval (state: pending)
4. Dashboard shows approval to admin
5. Admin clicks "Approve"
6. Server resolves approval, creates complete_step command
7. Server sends silent push to device
8. Device wakes, syncs, receives command
9. Device calls engine.complete_step()
10. Workflow continues from where it left offOffline Behavior#
When the device is offline, workflows continue executing locally. Status changes and approval requests accumulate in a local SQLite outbox. When connectivity returns, the next sync drains the full outbox in a single request.
- —Workflows advance independently of server availability
- —State changes coalesce — multiple transitions for the same instance collapse to the latest state
- —Sync attempts fail silently (logged, counter reset to retry later)
- —Commands queued on the server are delivered on the first successful sync
Server API Endpoints#
| Method | Path | Purpose |
|---|---|---|
| POST | /mobile/sync | Bidirectional sync (status + approvals + commands) |
| POST | /mobile/devices/register | Register push token for a device |
| GET | /mobile/approvals | List pending approvals from all devices |
| POST | /mobile/approvals/{id}/resolve | Admin resolves an approval |
| GET | /mobile/status | List all mobile instance statuses |
| POST | /mobile/commands | Send a command to a specific device |
All endpoints require x-api-key (tenant API key) and x-device-id headers. All queries are scoped by tenant_id for full tenant isolation.
Configuration#
Mobile sync is disabled by default. Enable it and configure push notifications via environment variables or TOML config.
# Enable mobile sync
ORCH8_MOBILE_SYNC_ENABLED=true
# Push provider: "apns", "fcm", or "" (disabled)
ORCH8_PUSH_PROVIDER=apns
# APNs configuration
ORCH8_APNS_KEY_PATH=/secrets/apns-auth-key.p8
ORCH8_APNS_KEY_ID=ABC123DEFG
ORCH8_APNS_TEAM_ID=TEAM123456
ORCH8_APNS_TOPIC=io.orch8.example
# Or FCM configuration
# ORCH8_PUSH_PROVIDER=fcm
# ORCH8_FCM_CREDENTIALS_PATH=/secrets/firebase-sa.json
# ORCH8_FCM_PROJECT_ID=my-project-id
# Limits
ORCH8_MOBILE_SYNC_MAX_DEVICES=1000
ORCH8_MOBILE_SYNC_COMMAND_TTL_SECS=604800
ORCH8_MOBILE_SYNC_APPROVAL_TTL_SECS=86400TOML config
[mobile_sync]
enabled = true
max_devices = 1000
command_ttl_secs = 604800
approval_ttl_secs = 86400
[mobile_sync.push]
provider = "apns"
[mobile_sync.push.apns]
key_path = "/secrets/apns-auth-key.p8"
key_id = "ABC123DEFG"
team_id = "TEAM123456"
topic = "io.orch8.example"
sandbox = falseDeployment#
Docker
docker run --rm -p 8080:8080 \
-e ORCH8_MOBILE_SYNC_ENABLED=true \
-e ORCH8_PUSH_PROVIDER=apns \
-e ORCH8_APNS_KEY_ID=ABC123DEFG \
-e ORCH8_APNS_TEAM_ID=TEAM123456 \
-e ORCH8_APNS_TOPIC=io.orch8.example \
-v /path/to/apns-key.p8:/secrets/apns-auth-key.p8:ro \
-e ORCH8_APNS_KEY_PATH=/secrets/apns-auth-key.p8 \
ghcr.io/orch8-io/engine:latestNo push provider
When ORCH8_PUSH_PROVIDER is empty, the engine uses a no-op provider. Push calls are silently ignored. Mobile relies entirely on heartbeat polling (10-15 min). This is valid for development, staging, or when push infrastructure is unavailable.
Multi-replica
Mobile sync is stateless — any engine replica can serve any device. The database is the coordination point. No sticky sessions needed. Push dispatch is fire-and-forget with heartbeat fallback.
Security#
- —Authentication — Same tenant API key as other endpoints. Device identity via x-device-id header (UUID generated on first launch).
- —Tenant isolation — All queries scoped by tenant_id. A device can only see/affect instances belonging to its tenant.
- —Command validation — Server validates commands before queuing — verifies approval exists and is pending, verifies instance belongs to device.
- —Push token security — Push tokens stored server-side only, never exposed via API. Silent push contains no sensitive data.