Skip to content

Soulacy Mobile

Soulacy Mobile is an always-on outbound channel for the native iOS and Android apps. Delivery first writes the complete result to the workspace's durable SQLite inbox. APNs and FCM are only discreet wake-up signals, so a missing or temporarily failing push service never loses an agent result.

Send a scheduled result

schedule:
  cron: "0 8 * * *"
  output:
    channel: mobile
    to: all

Destinations are all, user:<authenticated-subject>, or device:<installation-id>. The channel test endpoint defaults to all when no destination is supplied.

The iOS app registers its installation through /api/v1/mobile/devices, reads /api/v1/mobile/deliveries, and maintains read receipts per device. The System agent is filtered at the API boundary and is never returned to iOS.

Native alerts

Durable inbox delivery needs no additional configuration. A Personal gateway can wake a device directly through APNs. Mount the downloaded Apple .p8 key read-only into the container, then set:

SOULACY_MOBILE_APNS_TEAM_ID=YOUR_APPLE_TEAM_ID
SOULACY_MOBILE_APNS_KEY_ID=YOUR_APNS_KEY_ID
SOULACY_MOBILE_APNS_PRIVATE_KEY_FILE=/run/secrets/soulacy-apns-key.p8

Never commit the .p8 key or paste it into .env. The key file should be readable only by the service account. Direct APNs supports both sandbox and production device tokens and is the recommended Personal-edition configuration.

Alternatively, configure a trusted APNs relay:

SOULACY_MOBILE_PUSH_RELAY_URL=https://push.example.com
SOULACY_MOBILE_PUSH_RELAY_TOKEN=replace-with-a-secret

The gateway posts a token, APNs environment, bundle ID, delivery ID, and a soulacy://delivery/<id> deep link to POST <relay>/v1/push. Notification text is intentionally generic; the sensitive output is fetched from the authenticated gateway after the user opens the app.

Android (FCM)

The Android app (package dev.soulacy.android) registers through the same POST /api/v1/mobile/devices endpoint with "platform": "android":

{"id": "<installation-id>", "name": "Pixel 9", "platform": "android",
 "bundle_id": "dev.soulacy.android", "push_token": "<FCM registration token>",
 "notifications_enabled": true}

platform is ios when omitted, and any other value is rejected. For Android, bundle_id may be omitted but otherwise must be dev.soulacy.android; push_token is the opaque FCM registration token (at most 4096 characters, no whitespace) and is stored exactly as sent. push_environment and live_start_token are ignored, and Live Activities are never sent to Android devices.

The gateway sends through the FCM HTTP v1 API as a Google service account. Create a service-account key with the Firebase Cloud Messaging permission in the Firebase project of the Android app, mount the JSON file read-only into the container, then set:

SOULACY_MOBILE_FCM_CREDENTIALS_FILE=/run/secrets/soulacy-fcm-service-account.json
# Optional: defaults to project_id from the credentials file.
SOULACY_MOBILE_FCM_PROJECT_ID=your-firebase-project

Never commit the service-account file or paste it into .env. The gateway signs its own OAuth2 assertion with the key, exchanges it at the file's token_uri for an access token, and reuses that token until shortly before it expires. FCM can be configured alongside APNs or a relay: iOS devices keep using APNs or the relay, Android devices use FCM.

Every push is a data-only, high-priority message with no notification block, so the app decides how to render it. Like APNs, it is only a wake-up: the text is generic and the app fetches the content from the authenticated gateway. The data map holds only strings:

Key Value
category SOULACY_APPROVAL, SOULACY_DELIVERY, SOULACY_TRIGGER, ...
title, body generic alert text
deep_link for example soulacy://approval/<call_id> or soulacy://delivery/<id>
thread_id grouping key
time_sensitive "true" or "false"
delivery_id present only for inbox deliveries

Feature-specific keys ride along in the same map, for example call_id, agent_id, session_id and tool on approvals. When FCM reports a token as unregistered (HTTP 404 or UNREGISTERED), the gateway clears that device's push token until the app registers a fresh one.

Lock-screen approvals

When an agent needs your approval for a tool call, every paired phone that belongs to the same signed-in user receives a time-sensitive push in the SOULACY_APPROVAL category with Approve and Deny actions. Deciding from the lock screen calls the approvals API in the background; the app does not have to open. Tapping the notification opens the approval instead. The push carries only the agent name and tool name; the arguments are fetched from the authenticated gateway.

Approvals still go through the same broker as the web, so a decision taken on the phone is visible everywhere, and an approval that has already been decided reports that instead of running twice.

Location triggers

Agents declared with trigger: location are listed for paired phones at GET /api/v1/mobile/triggers?device_id=<installation-id>. The phone monitors those regions and reports a crossing with POST /api/v1/mobile/triggers/<agent-id>/fire. The gateway checks that the event matches the trigger's on, that the phone is allowed by device, and that the trigger's cooldown has passed, then starts the run and delivers the reply to that phone through this channel. See the SOUL.yaml reference for the block.

Live Activities

A running agent can sit on the lock screen and in the Dynamic Island as a Live Activity: the agent's name, what it is doing, how many steps it has taken, elapsed time, and Approve/Deny buttons whenever it is waiting on you. Nothing is rendered on the gateway; it pushes a small content state and the phone draws it.

Which runs appear:

  • a background run (cron, webhook, location, another agent) as soon as it calls its first tool;
  • any run, including interactive chat, the moment it needs an approval.

The phone sends an ActivityKit push-to-start token with its device registration (live_start_token). The gateway uses it to begin the activity even when the app is closed. Once the activity is on screen the phone reports the per-activity update token with POST /api/v1/mobile/activities (device_id, agent_id, session_id, token, environment), and the gateway pushes progress, "waiting on you", and the final state, then lets the phone dismiss it a few minutes later. Routine progress is throttled and sent at low priority; waiting and finished states are sent at once.

With direct APNs the pushes go to Apple with apns-push-type: liveactivity and topic dev.soulacy.ios.push-type.liveactivity. A relay receives them at POST <relay>/v1/live as {token, environment, bundle_id, event, priority, payload} where payload is the exact APNs body. Turn the feature off per phone under Settings → Lock screen; the phone then stops sending its start token.

Siri and Shortcuts

The iOS app exposes App Intents so agents work without opening the app: Ask an agent, Remember something (adds an adaptive-memory fact), Approve the pending action, and Run an agent. They use the same paired credential and the same APIs as the app, so nothing new has to be configured on the gateway.

Apple Watch

The iPhone build carries a watch app. It pairs through the phone once: the phone calls POST /pairing/tokens with no body, which issues a code for the same subject, and the watch redeems it and keeps the resulting managed key. From then on the watch calls GET /approvals, POST /approvals/:id/approve or …/deny, GET /agents and POST /chat (voice mode) directly, so it shows up as its own key in the household list and can be revoked on its own. Nothing else has to be configured on the gateway.

Agent-built UI

An agent can draw a native card on a paired phone with the canvas.present device command (through the mobile.invoke tool, once the phone has the Soulacy canvas capability on). Besides the original title, body and items, a card may carry typed components, rendered with native controls:

Type Fields
text markdown
checklist title, items: [{label, done}]
form id, fields: [{name, label, kind: text\|number\|choice\|toggle\|date, options, required, default}], submit (button label)
chart title, kind: bar\|line, labels, series: [{label, values}]
metric label, value, unit, trend: up\|down\|flat

A card with a form (or a submit label at the top level) keeps the command running until the person submits or dismisses it, or the command expires, so set expires_in_seconds on mobile.invoke (up to 900). The result then carries answers keyed by field name and the state of every checklist:

{"presented": true, "submitted": true, "form_id": "pick",
 "answers": {"option": "Beach", "notes": "leave early"},
 "checklists": {"Pack": [{"label": "Passport", "done": true}]}}

Limits: 12 components, one form of up to 12 fields, checklists of up to 30 items, charts of up to 6 series and 60 points. The gateway refuses anything else with a message naming the vocabulary, so agents learn it from the error.

Household: one gateway, several people

Every paired phone signs in as a person. POST /api/v1/pairing/tokens with no body pairs a phone as the caller; with {"name": "Priya", "role": "operator"|"viewer"} an admin pairs a phone for someone else, whose subject becomes a slug of the name (priya). The credential minted on redeem carries that subject and role, so adaptive memory, approvals, the inbox and device commands are all scoped to that person, exactly as they are for the owner. GET /api/v1/pairing/members (admin) lists everyone with a paired phone; revoking their keys through /admin/api-keys/:id unpairs them. Companion keys minted before identities existed are the owner's and are treated as such.