oomi-ai 0.3.5 → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -145,12 +145,40 @@ Optional fields:
145
145
  - `defaultSessionKey`
146
146
  - `requestTimeoutMs`
147
147
 
148
+ Hosted Oomi-managed agents also include a `modelRelay` block under the default account. When enabled, `oomi-ai` applies an OpenClaw custom provider named `oomi-managed` that points at Oomi's OpenAI-compatible metered relay:
149
+
150
+ ```json
151
+ {
152
+ "channels": {
153
+ "oomi": {
154
+ "accounts": {
155
+ "default": {
156
+ "modelRelay": {
157
+ "enabled": true,
158
+ "backendUrl": "https://api.oomi.ai",
159
+ "openaiBasePath": "/v1/model/relay/openai",
160
+ "openclawProviderId": "oomi-managed",
161
+ "openclawModelRef": "oomi-managed/google/gemini-2.5-flash",
162
+ "provider": "openrouter",
163
+ "model": "google/gemini-2.5-flash",
164
+ "userId": "usr_...",
165
+ "deviceId": "oomi-hosted-...",
166
+ "internalKeyEnv": "OOMI_HOSTED_BOOTSTRAP_INTERNAL_KEY"
167
+ }
168
+ }
169
+ }
170
+ }
171
+ }
172
+ }
173
+ ```
174
+
175
+ The config stores only the internal-key environment variable name. The secret value stays in the runtime environment.
176
+
148
177
  ## Persona App API Tools
149
178
 
150
179
  Component-composed persona apps are the default Oomi mini-app path. Use these commands when the user asks about or updates an existing Oomi persona app such as Fitness Today:
151
180
 
152
181
  ```bash
153
- oomi fitness show --json
154
182
  oomi persona-apps list --json
155
183
  oomi persona-apps show fitness-today --json
156
184
  oomi persona-apps apply-action fitness-today --action fitness.complete_workout --payload-json '{"goalId":"easy-run","minutes":20}' --json
@@ -159,21 +187,17 @@ oomi persona-apps apply-action fitness-today --action fitness.complete_workout -
159
187
  Rules for agents:
160
188
 
161
189
  - Answer from returned `appState.bindings`, not from memory.
162
- - For Fitness questions, prefer `oomi fitness show --json` because it returns the Fitness mini-app state and approved health context together.
163
190
  - Apply updates only through approved `persona-apps apply-action` actions.
164
191
  - If the persona app does not exist, tell the user to add it from the Oomi client first.
165
192
  - Do not create local persona UI projects.
166
193
 
167
- For the Fitness slice:
194
+ For the Fitness v0 slice:
168
195
 
169
196
  ```bash
170
- oomi persona-apps apply-action fitness-today --action fitness.set_goal --payload-json '{"goalId":"move_more"}' --json
171
197
  oomi persona-apps apply-action fitness-today --action fitness.complete_goal --payload-json '{"goalId":"easy-run"}' --json
172
198
  oomi persona-apps apply-action fitness-today --action fitness.complete_workout --payload-json '{"goalId":"easy-run","minutes":20}' --json
173
199
  ```
174
200
 
175
- Supported `fitness.set_goal` values are `move_more`, `sleep_better`, and `build_workout_habit`.
176
-
177
201
  ## Context Tools
178
202
 
179
203
  Use context tools when the user asks a data-backed question that should come from Oomi-approved mobile permissions, not model memory. For health and fitness questions, read the latest backend HealthKit context first:
@@ -12,7 +12,6 @@ Get OpenClaw into a state where:
12
12
  - Oomi device-backed voice can start cleanly
13
13
  - the bridge is supervised or restartable
14
14
  - component-composed persona apps can be inspected and updated through Oomi API tools
15
- - Fitness state can be inspected with one command that combines mini-app state and approved health context
16
15
  - approved user context, such as HealthKit data synced by the Oomi mobile app, can be read through Oomi backend context tools
17
16
  - the user never has to paste raw pair identifiers or gateway secrets manually when managed connect is available
18
17
 
@@ -20,27 +19,24 @@ Get OpenClaw into a state where:
20
19
 
21
20
  If the user asks to connect OpenClaw to Oomi chat or voice:
22
21
 
23
- 1. Refresh bootstrap first:
22
+ 1. For a first-time install or missing local pairing state, run the bootstrap exactly once using the requested app URL:
24
23
  - `curl -fsSL https://www.oomi.ai/install.sh | bash`
25
- 2. Ensure `oomi-ai` is installed:
26
- - `pnpm add -g oomi-ai@latest`
27
- - fallback: `npm install -g oomi-ai@latest`
28
- 3. Pair the device without starting a legacy one-off flow:
29
- - `oomi openclaw pair --app-url https://www.oomi.ai --no-start`
30
- 4. Install the OpenClaw plugin:
31
- - `openclaw plugins install oomi-ai@latest`
32
- 5. Print the config block and secrets:
24
+ - The bootstrap installs or updates `oomi-ai`, performs the one initial pair, installs the plugin, and starts the saved bridge in detached mode.
25
+ - Do not pair again after the bootstrap finishes.
26
+ 2. Print the config block and secrets:
33
27
  - `oomi openclaw plugin --show-secrets --backend-url https://api.oomi.ai`
34
- 6. Apply the printed `channels.oomi.accounts.default` config.
35
- 7. Restart OpenClaw.
36
- 8. Start or repair the bridge:
28
+ 3. Apply the printed `channels.oomi.accounts.default` config.
29
+ 4. Restart OpenClaw.
30
+ 5. Start or repair the bridge from saved pairing state:
37
31
  - `oomi openclaw bridge ensure --detach`
38
32
  - if stale: `oomi openclaw bridge restart --detach`
39
33
  - on macOS, prefer supervision: `oomi openclaw bridge service install`
34
+ 6. If the Auth Link printed by bootstrap is missing or expired, create a replacement without re-pairing:
35
+ - `oomi openclaw invite --app-url https://www.oomi.ai`
40
36
 
41
- If the user provides a specific app URL, use it directly in the pair flow:
37
+ If the machine is already paired, do not rerun bootstrap. Use `invite` for a fresh Auth Link and `bridge ensure` or `bridge restart` for runtime recovery.
42
38
 
43
- - `oomi openclaw pair --app-url <URL> --no-start`
39
+ If the user provides a specific app URL, substitute it in both the bootstrap URL and the `invite --app-url` command.
44
40
 
45
41
  ## Required User-Facing Result
46
42
 
@@ -249,17 +245,8 @@ When a user asks about an existing persona app:
249
245
  4. To update state, use a validated action: `oomi persona-apps apply-action <slug> --action <action-id> --payload-json '<json>' --json`.
250
246
  5. If `persona-apps list` returns no app for the user's request, tell the user they can add that persona from the Oomi client so the client can collect the right permissions and data sources.
251
247
 
252
- For Fitness questions, prefer the combined read command first:
248
+ For the Fitness v0 slice:
253
249
 
254
- ```bash
255
- oomi fitness show --json
256
- ```
257
-
258
- It returns the linked Fitness persona app plus the latest approved health context in one payload.
259
-
260
- For the Fitness slice:
261
-
262
- - Use `fitness.set_goal` with payload `{"goalId":"move_more"}`, `{"goalId":"sleep_better"}`, or `{"goalId":"build_workout_habit"}` to set the active focus.
263
250
  - Use `fitness.complete_goal` with payload `{"goalId":"easy-run"}` to mark a known goal complete.
264
251
  - Use `fitness.complete_workout` with payload like `{"goalId":"easy-run","minutes":20}` when the user says they finished a workout.
265
252
 
@@ -292,3 +279,19 @@ Rules:
292
279
  - Never present stale HealthKit values as current values.
293
280
  - Do not infer unavailable health fields.
294
281
  - Do not request HealthKit directly from the phone; the Oomi mobile app owns permission prompts and syncs approved data to the backend.
282
+
283
+ ## Phone Notifications
284
+
285
+ To reach the user on their phone when they are not in the conversation, send a push notification:
286
+
287
+ ```bash
288
+ oomi notify "Your 7-day plan is ready. Want to look now?" --title "Fitness" --json
289
+ ```
290
+
291
+ Rules:
292
+
293
+ - Notify only for things worth interrupting the user for: a reminder they asked for, a finished task, or something time-sensitive. Say everything else in chat.
294
+ - Keep the message short and specific. The title defaults to your agent name.
295
+ - Tapping the notification opens the Oomi app to the conversation, so follow up in chat.
296
+ - If `notification.status` is `skipped`, tell the user `notification.guidance` instead of retrying.
297
+ - If the request is rate limited, do not retry until `retryAfterSeconds` has passed.
package/bin/oomi-ai.js CHANGED
@@ -233,15 +233,16 @@ Commands:
233
233
  persona-apps apply-action <slug>
234
234
  Apply an approved persona app action through the Oomi backend.
235
235
 
236
- fitness show
237
- Show the linked Fitness persona app plus approved health context.
238
-
239
236
  context health
240
237
  Show the latest account-linked health context available to this paired OpenClaw device.
241
238
  context health sync [--wait]
242
239
  Request the linked Oomi mobile app to sync fresh HealthKit context.
243
240
  Use --wait to poll until fresh context is uploaded or the timeout expires.
244
241
 
242
+ notify "<message>" [--title TITLE]
243
+ Send the linked user a push notification on their phone. Use it for things worth
244
+ interrupting them for; say everything else in chat. Limited per hour.
245
+
245
246
  Common flags:
246
247
  --agents-file PATH Override AGENTS.md path
247
248
  --workspace PATH Override OpenClaw workspace root
@@ -382,18 +383,6 @@ function findRepoRoot(startDir) {
382
383
  return null;
383
384
  }
384
385
 
385
- function resolveRepoRoot(rootFlag) {
386
- const explicitRoot =
387
- typeof rootFlag === 'string' && rootFlag.trim()
388
- ? path.resolve(rootFlag.trim())
389
- : '';
390
- const repoRoot = explicitRoot || findRepoRoot(process.cwd()) || findRepoRoot(PACKAGE_ROOT);
391
- if (!repoRoot) {
392
- throw new Error('Could not locate repo root. Use --root <repo root>.');
393
- }
394
- return repoRoot;
395
- }
396
-
397
386
  function resolveSkillSource(cliRoot) {
398
387
  const packaged = path.join(PACKAGE_ROOT, 'skills', 'oomi');
399
388
  if (fs.existsSync(packaged)) {
@@ -587,32 +576,18 @@ async function handleContextHealthCommand(flags = {}) {
587
576
  printStructuredResult(payload, isTruthyFlag(flags.json));
588
577
  }
589
578
 
590
- async function handleFitnessShowCommand(flags = {}) {
579
+ async function handleNotifyCommand(message, flags = {}) {
591
580
  const client = createCliPersonaApiClient(flags);
592
- const listPayload = await client.listPersonaApps();
593
- const personaApps = Array.isArray(listPayload?.personaApps) ? listPayload.personaApps : [];
594
- const fitnessApp = personaApps.find((app) => app?.personaType === 'fitness') ||
595
- personaApps.find((app) => app?.slug === 'fitness-today');
596
-
597
- if (!fitnessApp?.slug) {
598
- throw new Error('Fitness persona app is not linked. Ask the user to add Fitness from the Oomi client first.');
599
- }
600
-
601
- const appPayload = await client.getPersonaApp({ slug: fitnessApp.slug });
602
- let healthPayload = null;
603
- let healthContextError = null;
604
- try {
605
- healthPayload = await client.getHealthContext();
606
- } catch (err) {
607
- healthContextError = err?.message || String(err);
608
- }
581
+ const payload = await client.sendNotification({
582
+ body: message,
583
+ title: typeof flags.title === 'string' ? flags.title : undefined,
584
+ kind: typeof flags.kind === 'string' ? flags.kind : undefined,
585
+ });
586
+ printStructuredResult(payload, isTruthyFlag(flags.json));
587
+ }
609
588
 
610
- printStructuredResult({
611
- ok: true,
612
- personaApp: appPayload?.personaApp || null,
613
- healthContext: healthPayload?.healthContext || null,
614
- healthContextError,
615
- }, isTruthyFlag(flags.json));
589
+ async function handleContextFinanceCommand(_flags = {}) {
590
+ throw new Error("oomi context finance is off until Gate A. Use the signed-in web Finance persona-app. Do not read bank balances from the connected OpenClaw shell.");
616
591
  }
617
592
 
618
593
  async function handleContextHealthSyncCommand(flags = {}) {
@@ -772,6 +747,28 @@ function readOpenclawGatewayConfig() {
772
747
  };
773
748
  }
774
749
 
750
+ function readOpenclawDefaultSessionKey() {
751
+ try {
752
+ const configPath = resolveOpenclawConfigPath();
753
+ if (!configPath || !fs.existsSync(configPath)) {
754
+ return '';
755
+ }
756
+
757
+ const parsed = JSON.parse(readFile(configPath));
758
+ const channels = parsed.channels || {};
759
+ const oomi = channels.oomi || {};
760
+ const accounts = oomi.accounts || {};
761
+ const defaultAccount = accounts.default || {};
762
+ const defaultSessionKey = typeof defaultAccount.defaultSessionKey === 'string'
763
+ ? defaultAccount.defaultSessionKey.trim()
764
+ : '';
765
+
766
+ return defaultSessionKey;
767
+ } catch {
768
+ return '';
769
+ }
770
+ }
771
+
775
772
  function resolveBridgeStatePath() {
776
773
  return resolveOpenclawBridgeStatePath();
777
774
  }
@@ -1080,6 +1077,25 @@ async function requestManagedPairCode({ appUrl, label }) {
1080
1077
  return payload;
1081
1078
  }
1082
1079
 
1080
+ async function requestBrokerPairCode({ brokerHttp, adminKey, label }) {
1081
+ const response = await fetch(`${brokerHttp.replace(/\/$/, '')}/v1/pair/start`, {
1082
+ method: 'POST',
1083
+ headers: {
1084
+ 'Content-Type': 'application/json',
1085
+ Authorization: `Bearer ${adminKey}`,
1086
+ },
1087
+ body: JSON.stringify({ label }),
1088
+ });
1089
+ const payload = await response.json().catch(() => ({}));
1090
+ if (!response.ok || !payload?.pairCode) {
1091
+ const message =
1092
+ (payload && typeof payload.error === 'string' && payload.error) ||
1093
+ `Broker pair start failed (${response.status})`;
1094
+ throw new Error(message);
1095
+ }
1096
+ return payload;
1097
+ }
1098
+
1083
1099
  async function requestConnectInviteLink({ backendHttp, appUrl, sessionKey, deviceToken }) {
1084
1100
  const response = await fetch(`${backendHttp.replace(/\/$/, '')}/v1/invite_links/start`, {
1085
1101
  method: 'POST',
@@ -1115,6 +1131,35 @@ async function fetchManagedGatewayConfig({ appUrl }) {
1115
1131
  return payload;
1116
1132
  }
1117
1133
 
1134
+ function shouldRewriteLoopbackBrokerForAppHost(appHostname) {
1135
+ return String(appHostname || '').trim().toLowerCase() === 'host.docker.internal';
1136
+ }
1137
+
1138
+ function isLoopbackBrokerHostname(hostname) {
1139
+ const normalized = String(hostname || '').trim().toLowerCase();
1140
+ return normalized === '127.0.0.1' || normalized === 'localhost' || normalized === '::1';
1141
+ }
1142
+
1143
+ function rewriteLoopbackBrokerUrlForAppHost(rawUrl, appUrl) {
1144
+ const value = String(rawUrl || '').trim();
1145
+ if (!value) return value;
1146
+
1147
+ try {
1148
+ const brokerUrl = new URL(value);
1149
+ const app = new URL(String(appUrl || '').trim());
1150
+ if (!isLoopbackBrokerHostname(brokerUrl.hostname)) {
1151
+ return value;
1152
+ }
1153
+ if (!shouldRewriteLoopbackBrokerForAppHost(app.hostname)) {
1154
+ return value;
1155
+ }
1156
+ brokerUrl.hostname = app.hostname;
1157
+ return brokerUrl.toString();
1158
+ } catch {
1159
+ return value;
1160
+ }
1161
+ }
1162
+
1118
1163
  function base64UrlEncode(value) {
1119
1164
  return Buffer.from(value)
1120
1165
  .toString('base64')
@@ -1773,48 +1818,6 @@ function printAssistantFinalDebugResult(result, asJson) {
1773
1818
  }
1774
1819
  }
1775
1820
 
1776
- function resolveCommandFromPath(commandName) {
1777
- const normalized = String(commandName || '').trim();
1778
- if (!normalized) return '';
1779
- try {
1780
- const probe = spawnSync(process.platform === 'win32' ? 'where' : 'which', [normalized], {
1781
- encoding: 'utf8',
1782
- stdio: ['ignore', 'pipe', 'ignore'],
1783
- });
1784
- if (probe.status !== 0) return '';
1785
- const firstLine = String(probe.stdout || '')
1786
- .split(/\r?\n/)
1787
- .map((line) => line.trim())
1788
- .find(Boolean);
1789
- return firstLine || '';
1790
- } catch {
1791
- return '';
1792
- }
1793
- }
1794
-
1795
- function resolveExecutable(candidates = []) {
1796
- for (const candidate of candidates) {
1797
- if (!candidate) continue;
1798
- const value = String(candidate).trim();
1799
- if (!value) continue;
1800
- if (path.isAbsolute(value) && fs.existsSync(value)) {
1801
- return value;
1802
- }
1803
- if (value.includes(path.sep) || value.includes('/')) {
1804
- const resolved = path.resolve(value);
1805
- if (fs.existsSync(resolved)) {
1806
- return resolved;
1807
- }
1808
- continue;
1809
- }
1810
- const fromPath = resolveCommandFromPath(value);
1811
- if (fromPath) {
1812
- return fromPath;
1813
- }
1814
- }
1815
- return '';
1816
- }
1817
-
1818
1821
  async function handleOpenclawDebugCommand(action, flags) {
1819
1822
  const normalizedAction = String(action || '').trim().toLowerCase();
1820
1823
  const frameFile =
@@ -2558,11 +2561,13 @@ async function startOpenclawBridge(flags) {
2558
2561
  if (!deviceToken) {
2559
2562
  throw new Error('Broker pair claim did not return deviceToken.');
2560
2563
  }
2564
+ const configSessionKey = readOpenclawDefaultSessionKey();
2561
2565
  writeBridgeState({
2562
2566
  brokerHttp,
2563
2567
  brokerWs,
2564
2568
  deviceId,
2565
2569
  deviceToken,
2570
+ sessionKey: configSessionKey || 'agent:main:webchat:channel:oomi',
2566
2571
  claimedAt: new Date().toISOString(),
2567
2572
  expiresAt: claimed.expiresAt || null,
2568
2573
  });
@@ -3837,7 +3842,24 @@ async function pairAndStartOpenclawBridge(flags) {
3837
3842
 
3838
3843
  console.log(`Pairing OpenClaw host with Oomi app: ${appUrl}`);
3839
3844
  const managedConfig = await fetchManagedGatewayConfig({ appUrl });
3840
- const pairStarted = await requestManagedPairCode({ appUrl, label });
3845
+ const brokerHttp = rewriteLoopbackBrokerUrlForAppHost(managedConfig.brokerHttpUrl, appUrl);
3846
+ let pairStarted;
3847
+ try {
3848
+ pairStarted = await requestManagedPairCode({ appUrl, label });
3849
+ } catch (err) {
3850
+ const brokerAdminKey = String(process.env.OOMI_CHAT_BROKER_ADMIN_KEY || '').trim();
3851
+ if (!brokerAdminKey) {
3852
+ throw err;
3853
+ }
3854
+ console.warn(
3855
+ `[oomi] Managed pair start via app failed (${extractErrorMessage(err)}); falling back to direct broker pair/start.`
3856
+ );
3857
+ pairStarted = await requestBrokerPairCode({
3858
+ brokerHttp,
3859
+ adminKey: brokerAdminKey,
3860
+ label,
3861
+ });
3862
+ }
3841
3863
  const pairCode = String(pairStarted.pairCode || '').trim().toUpperCase();
3842
3864
  if (!pairCode) {
3843
3865
  throw new Error('Pairing code missing from backend response.');
@@ -3845,7 +3867,7 @@ async function pairAndStartOpenclawBridge(flags) {
3845
3867
 
3846
3868
  console.log(`Received pair code: ${pairCode}`);
3847
3869
  const claimed = await claimBridgeDeviceToken({
3848
- brokerHttp: managedConfig.brokerHttpUrl,
3870
+ brokerHttp,
3849
3871
  pairCode,
3850
3872
  deviceId,
3851
3873
  });
@@ -3854,9 +3876,11 @@ async function pairAndStartOpenclawBridge(flags) {
3854
3876
  throw new Error('Broker pair claim did not return deviceToken.');
3855
3877
  }
3856
3878
 
3857
- const brokerWs = String(flags['broker-ws'] || managedConfig.brokerDeviceWsUrl).trim();
3879
+ const brokerWs = String(
3880
+ flags['broker-ws'] || rewriteLoopbackBrokerUrlForAppHost(managedConfig.brokerDeviceWsUrl, appUrl)
3881
+ ).trim();
3858
3882
  writeBridgeState({
3859
- brokerHttp: managedConfig.brokerHttpUrl,
3883
+ brokerHttp,
3860
3884
  brokerWs,
3861
3885
  deviceId,
3862
3886
  deviceToken,
@@ -3866,7 +3890,7 @@ async function pairAndStartOpenclawBridge(flags) {
3866
3890
  });
3867
3891
 
3868
3892
  const invite = await requestConnectInviteLink({
3869
- backendHttp: managedConfig.brokerHttpUrl,
3893
+ backendHttp: brokerHttp,
3870
3894
  appUrl,
3871
3895
  sessionKey,
3872
3896
  deviceToken,
@@ -3940,9 +3964,11 @@ async function createOpenclawInviteLink(flags) {
3940
3964
  ''
3941
3965
  ).trim();
3942
3966
  const appUrl = String(flags['app-url'] || process.env.OOMI_APP_URL || 'http://127.0.0.1:3456').trim();
3967
+ const configSessionKey = readOpenclawDefaultSessionKey();
3943
3968
  const sessionKey = String(
3944
3969
  flags['session-key'] ||
3945
3970
  process.env.OOMI_SESSION_KEY ||
3971
+ configSessionKey ||
3946
3972
  bridgeState.sessionKey ||
3947
3973
  'agent:main:webchat:channel:oomi'
3948
3974
  ).trim();
@@ -4403,11 +4429,6 @@ async function main() {
4403
4429
  return;
4404
4430
  }
4405
4431
 
4406
- if (command === 'fitness' && subcommand === 'show') {
4407
- await handleFitnessShowCommand(args.flags);
4408
- return;
4409
- }
4410
-
4411
4432
  if (command === 'context' && subcommand === 'health') {
4412
4433
  if (args.positionals[0] === 'sync') {
4413
4434
  await handleContextHealthSyncCommand(args.flags);
@@ -4417,6 +4438,22 @@ async function main() {
4417
4438
  return;
4418
4439
  }
4419
4440
 
4441
+ if (command === 'context' && subcommand === 'finance') {
4442
+ await handleContextFinanceCommand(args.flags);
4443
+ return;
4444
+ }
4445
+
4446
+ if (command === 'notify') {
4447
+ // parseArgs reads the first word as a subcommand, so the message is everything after `notify`.
4448
+ const message = [subcommand, ...args.positionals].filter(Boolean).join(' ').trim()
4449
+ || (typeof args.flags.body === 'string' ? args.flags.body.trim() : '');
4450
+ if (!message) {
4451
+ throw new Error('A message is required. Usage: oomi notify "<message>" [--title <title>]');
4452
+ }
4453
+ await handleNotifyCommand(message, args.flags);
4454
+ return;
4455
+ }
4456
+
4420
4457
  console.error(`Unknown command: ${command} ${subcommand || ''}`.trim());
4421
4458
  usage();
4422
4459
  process.exit(1);
@@ -4457,4 +4494,5 @@ export {
4457
4494
  isGatewayRunStartedFrame,
4458
4495
  isBridgeWorkerCommand,
4459
4496
  parsePositiveInteger,
4497
+ rewriteLoopbackBrokerUrlForAppHost,
4460
4498
  };
@@ -0,0 +1,150 @@
1
+ const DEFAULT_ENDPOINT_PATH = '/v1/model/relay/chat';
2
+ const DEFAULT_REQUEST_TIMEOUT_MS = 60000;
3
+ const DEFAULT_REQUEST_ORIGIN = 'hosted_openclaw';
4
+
5
+ function toString(value, fallback = '') {
6
+ return typeof value === 'string' && value.trim() ? value.trim() : fallback;
7
+ }
8
+
9
+ function toPositiveInteger(value, fallback, { min = 1, max = Number.MAX_SAFE_INTEGER } = {}) {
10
+ const parsed = Number.parseInt(String(value || ''), 10);
11
+ if (!Number.isFinite(parsed) || parsed < min) return fallback;
12
+ return Math.min(parsed, max);
13
+ }
14
+
15
+ function normalizeMessages(messages) {
16
+ return Array.isArray(messages)
17
+ ? messages
18
+ .map((message) => {
19
+ if (!message || typeof message !== 'object') return null;
20
+ const role = toString(message.role);
21
+ const content = toString(message.content);
22
+ if (!role || !content) return null;
23
+ return { role, content };
24
+ })
25
+ .filter(Boolean)
26
+ : [];
27
+ }
28
+
29
+ export function extractModelRelayConfig(cfg = {}, accountId = '') {
30
+ const channel = cfg?.channels?.oomi || cfg?.oomi || cfg;
31
+ const accounts = channel && typeof channel.accounts === 'object' ? channel.accounts : {};
32
+ const defaultAccountId = toString(accountId, toString(channel?.defaultAccountId, 'default'));
33
+ const account = accounts[defaultAccountId] || accounts.default || {};
34
+ const relay = account?.modelRelay || cfg?.modelRelay || {};
35
+
36
+ return {
37
+ enabled: relay.enabled === true,
38
+ backendUrl: toString(relay.backendUrl, toString(account.backendUrl)),
39
+ endpointPath: toString(relay.endpointPath, DEFAULT_ENDPOINT_PATH),
40
+ provider: toString(relay.provider),
41
+ model: toString(relay.model),
42
+ requestOrigin: toString(relay.requestOrigin, DEFAULT_REQUEST_ORIGIN),
43
+ userId: toString(relay.userId),
44
+ deviceId: toString(relay.deviceId),
45
+ internalKeyEnv: toString(relay.internalKeyEnv),
46
+ requestTimeoutMs: toPositiveInteger(relay.requestTimeoutMs, DEFAULT_REQUEST_TIMEOUT_MS, {
47
+ min: 2000,
48
+ max: 120000,
49
+ }),
50
+ };
51
+ }
52
+
53
+ function buildRelayUrl(backendUrl, endpointPath) {
54
+ const base = toString(backendUrl).replace(/\/+$/, '');
55
+ const path = `/${toString(endpointPath, DEFAULT_ENDPOINT_PATH).replace(/^\/+/, '')}`;
56
+ return `${base}${path}`;
57
+ }
58
+
59
+ function resolveInternalKey({ relayConfig, env = process.env, internalKey = '' }) {
60
+ const direct = toString(internalKey);
61
+ if (direct) return direct;
62
+
63
+ const envName = toString(relayConfig.internalKeyEnv);
64
+ if (!envName) return '';
65
+
66
+ return toString(env?.[envName]);
67
+ }
68
+
69
+ export function validateModelRelayConfig(relayConfig, { env = process.env, internalKey = '' } = {}) {
70
+ const config = relayConfig || {};
71
+ if (!config.enabled) {
72
+ return { ok: false, error: 'Oomi model relay is disabled.' };
73
+ }
74
+ if (!config.backendUrl) {
75
+ return { ok: false, error: 'Oomi model relay backendUrl is required.' };
76
+ }
77
+ if (!config.userId) {
78
+ return { ok: false, error: 'Oomi model relay userId is required.' };
79
+ }
80
+ if (!resolveInternalKey({ relayConfig: config, env, internalKey })) {
81
+ return { ok: false, error: 'Oomi model relay internal key is required.' };
82
+ }
83
+
84
+ return { ok: true };
85
+ }
86
+
87
+ export async function chatWithModelRelay({
88
+ cfg,
89
+ accountId = '',
90
+ messages,
91
+ maxOutputTokens,
92
+ temperature,
93
+ conversationKey = '',
94
+ requestOrigin = '',
95
+ fetchImpl = globalThis.fetch,
96
+ env = process.env,
97
+ internalKey = '',
98
+ } = {}) {
99
+ if (typeof fetchImpl !== 'function') {
100
+ throw new Error('A fetch implementation is required for Oomi model relay chat.');
101
+ }
102
+
103
+ const relayConfig = extractModelRelayConfig(cfg, accountId);
104
+ const validation = validateModelRelayConfig(relayConfig, { env, internalKey });
105
+ if (!validation.ok) {
106
+ throw new Error(validation.error);
107
+ }
108
+
109
+ const normalizedMessages = normalizeMessages(messages);
110
+ if (normalizedMessages.length === 0) {
111
+ throw new Error('At least one model relay message is required.');
112
+ }
113
+
114
+ const controller = new AbortController();
115
+ const timeout = setTimeout(() => controller.abort(), relayConfig.requestTimeoutMs);
116
+
117
+ try {
118
+ const body = {
119
+ provider: relayConfig.provider || undefined,
120
+ model: relayConfig.model || undefined,
121
+ messages: normalizedMessages,
122
+ requestOrigin: toString(requestOrigin, relayConfig.requestOrigin),
123
+ maxOutputTokens: toPositiveInteger(maxOutputTokens, undefined),
124
+ temperature,
125
+ deviceId: relayConfig.deviceId || undefined,
126
+ conversationKey: toString(conversationKey) || undefined,
127
+ };
128
+
129
+ const response = await fetchImpl(buildRelayUrl(relayConfig.backendUrl, relayConfig.endpointPath), {
130
+ method: 'POST',
131
+ headers: {
132
+ 'Content-Type': 'application/json',
133
+ 'X-Oomi-Internal-Key': resolveInternalKey({ relayConfig, env, internalKey }),
134
+ 'X-Oomi-User-Id': relayConfig.userId,
135
+ },
136
+ body: JSON.stringify(body),
137
+ signal: controller.signal,
138
+ });
139
+
140
+ const payload = await response.json().catch(() => ({}));
141
+ if (!response.ok) {
142
+ const error = toString(payload.error, `Oomi model relay failed with status ${response.status}.`);
143
+ throw new Error(error);
144
+ }
145
+
146
+ return payload;
147
+ } finally {
148
+ clearTimeout(timeout);
149
+ }
150
+ }
@@ -8,6 +8,15 @@ const DEFAULT_SESSION_KEY = 'agent:main:webchat:channel:oomi';
8
8
  const DEFAULT_GATEWAY_PORT = 18789;
9
9
  const DEFAULT_PLUGIN_TRUST_MODE = 'auto-discovery';
10
10
  const DEFAULT_MODEL_AUTH_MODE = 'oomi-managed';
11
+ const DEFAULT_MODEL_RELAY_PROVIDER = 'openrouter';
12
+ const DEFAULT_MODEL_RELAY_MODEL = 'google/gemini-2.5-flash';
13
+ const DEFAULT_MODEL_RELAY_ENDPOINT_PATH = '/v1/model/relay/chat';
14
+ const DEFAULT_MODEL_RELAY_OPENAI_BASE_PATH = '/v1/model/relay/openai';
15
+ const DEFAULT_MODEL_RELAY_REQUEST_ORIGIN = 'hosted_openclaw';
16
+ const DEFAULT_MODEL_RELAY_TIMEOUT_MS = 60000;
17
+ const DEFAULT_MODEL_RELAY_INTERNAL_KEY_ENV = 'OOMI_HOSTED_BOOTSTRAP_INTERNAL_KEY';
18
+ const DEFAULT_MODEL_RELAY_OPENCLAW_PROVIDER_ID = 'oomi-managed';
19
+ const DEFAULT_MODEL_RELAY_MAX_TOKENS = 1024;
11
20
 
12
21
  function trimString(value) {
13
22
  return typeof value === 'string' ? value.trim() : '';
@@ -62,6 +71,14 @@ function normalizeModelAuthMode(value) {
62
71
  return trimString(value) === 'provider-env' ? 'provider-env' : DEFAULT_MODEL_AUTH_MODE;
63
72
  }
64
73
 
74
+ function normalizeProviderId(value) {
75
+ const normalized = trimString(value)
76
+ .toLowerCase()
77
+ .replace(/[^a-z0-9._-]+/g, '-')
78
+ .replace(/^-+|-+$/g, '');
79
+ return normalized || DEFAULT_MODEL_RELAY_OPENCLAW_PROVIDER_ID;
80
+ }
81
+
65
82
  function resolveConfigPath(openclawHome, explicitPath = '') {
66
83
  const targetPath = trimString(explicitPath);
67
84
  if (targetPath) return path.resolve(targetPath);
@@ -129,7 +146,7 @@ function applyOomiChannelConfig(config, oomiChannel) {
129
146
  const accounts = typeof oomi.accounts === 'object' && oomi.accounts ? { ...oomi.accounts } : {};
130
147
  const defaultAccount = typeof accounts.default === 'object' && accounts.default ? { ...accounts.default } : {};
131
148
 
132
- accounts.default = {
149
+ const nextDefaultAccount = {
133
150
  enabled: true,
134
151
  requestTimeoutMs: normalizePositiveInteger(oomiChannel?.requestTimeoutMs, 15000),
135
152
  ...defaultAccount,
@@ -138,6 +155,16 @@ function applyOomiChannelConfig(config, oomiChannel) {
138
155
  defaultSessionKey: trimString(oomiChannel?.defaultSessionKey) || DEFAULT_SESSION_KEY,
139
156
  };
140
157
 
158
+ const modelRelay = buildModelRelayConfig(oomiChannel?.modelRelay, {
159
+ backendUrl: nextDefaultAccount.backendUrl,
160
+ });
161
+ if (modelRelay.enabled) {
162
+ nextDefaultAccount.modelRelay = modelRelay;
163
+ } else if (Object.prototype.hasOwnProperty.call(nextDefaultAccount, 'modelRelay')) {
164
+ delete nextDefaultAccount.modelRelay;
165
+ }
166
+
167
+ accounts.default = nextDefaultAccount;
141
168
  oomi.defaultAccountId = 'default';
142
169
  oomi.accounts = accounts;
143
170
  channels.oomi = oomi;
@@ -145,6 +172,110 @@ function applyOomiChannelConfig(config, oomiChannel) {
145
172
  return nextConfig;
146
173
  }
147
174
 
175
+ function buildModelRelayConfig(modelRelay, defaults = {}) {
176
+ const relay = modelRelay && typeof modelRelay === 'object' ? modelRelay : {};
177
+ const enabled = normalizeBoolean(relay.enabled, false);
178
+ const backendUrl = trimString(relay.backendUrl) || trimString(defaults.backendUrl);
179
+ const openclawProviderId = normalizeProviderId(relay.openclawProviderId);
180
+ const model = trimString(relay.model) || DEFAULT_MODEL_RELAY_MODEL;
181
+
182
+ return {
183
+ enabled,
184
+ backendUrl,
185
+ endpointPath: trimString(relay.endpointPath) || DEFAULT_MODEL_RELAY_ENDPOINT_PATH,
186
+ openaiBasePath: trimString(relay.openaiBasePath) || DEFAULT_MODEL_RELAY_OPENAI_BASE_PATH,
187
+ openclawProviderId,
188
+ openclawModelRef: `${openclawProviderId}/${model}`,
189
+ provider: trimString(relay.provider) || DEFAULT_MODEL_RELAY_PROVIDER,
190
+ model,
191
+ requestOrigin: trimString(relay.requestOrigin) || DEFAULT_MODEL_RELAY_REQUEST_ORIGIN,
192
+ userId: trimString(relay.userId),
193
+ deviceId: trimString(relay.deviceId),
194
+ internalKeyEnv: trimString(relay.internalKeyEnv) || DEFAULT_MODEL_RELAY_INTERNAL_KEY_ENV,
195
+ requestTimeoutMs: normalizePositiveInteger(relay.requestTimeoutMs, DEFAULT_MODEL_RELAY_TIMEOUT_MS),
196
+ maxTokens: normalizePositiveInteger(relay.maxTokens, DEFAULT_MODEL_RELAY_MAX_TOKENS),
197
+ };
198
+ }
199
+
200
+ function joinUrl(baseUrl, pathValue) {
201
+ const base = trimString(baseUrl).replace(/\/+$/, '');
202
+ const suffix = `/${trimString(pathValue).replace(/^\/+/, '')}`;
203
+ return `${base}${suffix}`;
204
+ }
205
+
206
+ function applyOomiManagedModelProviderConfig(config, modelRelay) {
207
+ if (!modelRelay?.enabled || !trimString(modelRelay.backendUrl) || !trimString(modelRelay.userId)) {
208
+ return config;
209
+ }
210
+
211
+ const nextConfig = { ...(config || {}) };
212
+ const modelsRoot = nextConfig.models && typeof nextConfig.models === 'object' ? { ...nextConfig.models } : {};
213
+ const providers =
214
+ modelsRoot.providers && typeof modelsRoot.providers === 'object' ? { ...modelsRoot.providers } : {};
215
+ const providerId = normalizeProviderId(modelRelay.openclawProviderId);
216
+ const modelId = trimString(modelRelay.model) || DEFAULT_MODEL_RELAY_MODEL;
217
+
218
+ modelsRoot.mode = trimString(modelsRoot.mode) || 'merge';
219
+ providers[providerId] = {
220
+ ...(providers[providerId] && typeof providers[providerId] === 'object' ? providers[providerId] : {}),
221
+ baseUrl: joinUrl(modelRelay.backendUrl, modelRelay.openaiBasePath),
222
+ api: 'openai-completions',
223
+ apiKey: `\${${modelRelay.internalKeyEnv}}`,
224
+ authHeader: true,
225
+ headers: {
226
+ 'X-Oomi-User-Id': modelRelay.userId,
227
+ 'X-Oomi-Device-Id': modelRelay.deviceId,
228
+ 'X-Oomi-Relay-Provider': modelRelay.provider,
229
+ 'X-Oomi-Request-Origin': modelRelay.requestOrigin,
230
+ },
231
+ models: [
232
+ {
233
+ id: modelId,
234
+ name: `Oomi Managed ${modelId}`,
235
+ reasoning: false,
236
+ input: ['text'],
237
+ cost: {
238
+ input: 0.0003,
239
+ output: 0.0025,
240
+ cacheRead: 0,
241
+ cacheWrite: 0,
242
+ },
243
+ contextWindow: 128000,
244
+ contextTokens: 96000,
245
+ maxTokens: normalizePositiveInteger(modelRelay.maxTokens, DEFAULT_MODEL_RELAY_MAX_TOKENS),
246
+ compat: {
247
+ requiresStringContent: true,
248
+ strictMessageKeys: true,
249
+ },
250
+ },
251
+ ],
252
+ };
253
+ modelsRoot.providers = providers;
254
+ nextConfig.models = modelsRoot;
255
+
256
+ const agents = nextConfig.agents && typeof nextConfig.agents === 'object' ? { ...nextConfig.agents } : {};
257
+ const defaults = agents.defaults && typeof agents.defaults === 'object' ? { ...agents.defaults } : {};
258
+ const defaultModel =
259
+ defaults.model && typeof defaults.model === 'object' && !Array.isArray(defaults.model)
260
+ ? { ...defaults.model }
261
+ : {};
262
+ const allowedModels =
263
+ defaults.models && typeof defaults.models === 'object' ? { ...defaults.models } : {};
264
+ const modelRef = `${providerId}/${modelId}`;
265
+
266
+ defaultModel.primary = modelRef;
267
+ allowedModels[modelRef] = {
268
+ ...(allowedModels[modelRef] && typeof allowedModels[modelRef] === 'object' ? allowedModels[modelRef] : {}),
269
+ alias: 'Oomi Managed',
270
+ };
271
+ defaults.model = defaultModel;
272
+ defaults.models = allowedModels;
273
+ agents.defaults = defaults;
274
+ nextConfig.agents = agents;
275
+
276
+ return nextConfig;
277
+ }
278
+
148
279
  function removeOomiChannelConfig(config) {
149
280
  const nextConfig = { ...(config || {}) };
150
281
  const channels =
@@ -193,6 +324,26 @@ export function buildOomiDevLocalProfile(options = {}) {
193
324
  defaultSessionKey: trimString(options.defaultSessionKey) || DEFAULT_SESSION_KEY,
194
325
  requestTimeoutMs: normalizePositiveInteger(options.requestTimeoutMs, 15000),
195
326
  pluginTrustMode,
327
+ modelRelay: buildModelRelayConfig(
328
+ {
329
+ enabled: normalizeBoolean(options.modelRelayEnabled, false),
330
+ backendUrl: options.modelRelayBackendUrl || options.backendUrl,
331
+ endpointPath: options.modelRelayEndpointPath,
332
+ openaiBasePath: options.modelRelayOpenaiBasePath,
333
+ openclawProviderId: options.modelRelayOpenclawProviderId,
334
+ provider: options.modelRelayProvider,
335
+ model: options.modelRelayModel,
336
+ requestOrigin: options.modelRelayRequestOrigin,
337
+ userId: options.modelRelayUserId,
338
+ deviceId: options.modelRelayDeviceId || options.deviceId,
339
+ internalKeyEnv: options.modelRelayInternalKeyEnv,
340
+ requestTimeoutMs: options.modelRelayRequestTimeoutMs,
341
+ maxTokens: options.modelRelayMaxTokens,
342
+ },
343
+ {
344
+ backendUrl: options.backendUrl,
345
+ }
346
+ ),
196
347
  },
197
348
  model: {
198
349
  preset: trimString(options.modelPreset || 'openrouter-free'),
@@ -240,6 +391,9 @@ export function applyOpenclawProfile({
240
391
 
241
392
  if (normalizeBoolean(profile?.oomiChannel?.enabled, false)) {
242
393
  nextConfig = applyOomiChannelConfig(nextConfig, profile.oomiChannel);
394
+ if (normalizeModelAuthMode(profile?.model?.authMode) === 'oomi-managed') {
395
+ nextConfig = applyOomiManagedModelProviderConfig(nextConfig, profile.oomiChannel.modelRelay);
396
+ }
243
397
  } else {
244
398
  nextConfig = removeOomiChannelConfig(nextConfig);
245
399
  }
@@ -149,6 +149,15 @@ export function createPersonaApiClient({
149
149
  });
150
150
  },
151
151
 
152
+ getFinanceContext() {
153
+ return getJson({
154
+ fetchImpl,
155
+ backendUrl: resolvedBackendUrl,
156
+ deviceToken: resolvedDeviceToken,
157
+ path: '/v1/context/finance',
158
+ });
159
+ },
160
+
152
161
  requestHealthContextSync({
153
162
  reason = 'agent_requested',
154
163
  } = {}) {
@@ -162,5 +171,24 @@ export function createPersonaApiClient({
162
171
  },
163
172
  });
164
173
  },
174
+
175
+ sendNotification({ body, title, kind } = {}) {
176
+ const safeBody = trimString(body);
177
+ if (!safeBody) {
178
+ throw new Error('Notification body is required.');
179
+ }
180
+
181
+ return postJson({
182
+ fetchImpl,
183
+ backendUrl: resolvedBackendUrl,
184
+ deviceToken: resolvedDeviceToken,
185
+ path: '/v1/notifications',
186
+ body: {
187
+ body: safeBody,
188
+ title: trimString(title) || undefined,
189
+ kind: trimString(kind) || undefined,
190
+ },
191
+ });
192
+ },
165
193
  };
166
194
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oomi-ai",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "description": "Managed Oomi channel, bridge, voice, and persona app API tooling for OpenClaw",
5
5
  "bin": {
6
6
  "oomi": "bin/oomi-ai.js"
@@ -59,6 +59,7 @@
59
59
  "files": [
60
60
  "bin/oomi-ai.js",
61
61
  "bin/sessionBridgeState.js",
62
+ "lib/modelRelayClient.js",
62
63
  "lib/openclawPaths.js",
63
64
  "lib/openclawProfile.js",
64
65
  "lib/personaApiClient.js",
@@ -19,14 +19,11 @@ The current files are the first shared contract for the vertical slice:
19
19
  OpenClaw agents should use the packaged CLI to inspect and update account-linked persona apps:
20
20
 
21
21
  ```bash
22
- oomi fitness show --json
23
22
  oomi persona-apps list --json
24
23
  oomi persona-apps show fitness-today --json
25
24
  oomi persona-apps apply-action fitness-today --action fitness.complete_workout --payload-json '{"goalId":"easy-run","minutes":20}' --json
26
25
  ```
27
26
 
28
- For Fitness, prefer `oomi fitness show --json` before answering because it returns the mini-app state and approved health context together.
29
-
30
27
  When answering data-backed health or fitness questions, agents should read approved backend context first:
31
28
 
32
29
  ```bash
@@ -42,3 +39,5 @@ oomi context health sync --wait --json
42
39
  Answer only after fresh context is uploaded. If the command times out, tell the user the phone sync is still pending.
43
40
 
44
41
  If a persona app is missing, ask the user to add it from the Oomi client first so platform-specific permissions and hydration can run.
42
+
43
+ Finance balances are a signed-in web persona-app. `oomi context finance` / `finance.context.read` stay off the connected shell until Gate A.
@@ -1,27 +1,44 @@
1
1
  {
2
2
  "schemaVersion": "persona-app.v1",
3
- "registryVersion": "2026-04-25.1",
3
+ "registryVersion": "2026-04-24.1",
4
4
  "contextTools": [
5
5
  {
6
6
  "id": "health.context.read",
7
7
  "command": "oomi context health --json",
8
8
  "endpoint": "/v1/context/health",
9
9
  "description": "Reads the latest account-linked health context synced by Oomi mobile clients.",
10
- "permissions": ["healthkit.read", "health_connect.read"],
10
+ "permissions": [
11
+ "healthkit.read",
12
+ "health_connect.read"
13
+ ],
11
14
  "readOnly": true,
12
- "targets": ["ios", "android", "web"]
15
+ "targets": [
16
+ "ios",
17
+ "android",
18
+ "web"
19
+ ]
13
20
  }
14
21
  ],
15
22
  "sharedComponents": [
16
23
  {
17
24
  "type": "shared.personaHero",
18
25
  "description": "Domain hero with title, subtitle, and a primary metric.",
19
- "targets": ["ios", "android", "web", "webspatial"]
26
+ "targets": [
27
+ "ios",
28
+ "android",
29
+ "web",
30
+ "webspatial"
31
+ ]
20
32
  },
21
33
  {
22
34
  "type": "shared.oomiNote",
23
35
  "description": "A primary-persona note with optional actions.",
24
- "targets": ["ios", "android", "web", "webspatial"]
36
+ "targets": [
37
+ "ios",
38
+ "android",
39
+ "web",
40
+ "webspatial"
41
+ ]
25
42
  }
26
43
  ],
27
44
  "personaPacks": [
@@ -32,37 +49,38 @@
32
49
  {
33
50
  "type": "fitness.todaySummary",
34
51
  "description": "Daily movement summary with progress, steps, and sleep context.",
35
- "targets": ["ios", "android", "web"]
36
- },
37
- {
38
- "type": "fitness.dataFreshnessCard",
39
- "description": "Shows whether mobile health context is fresh enough for decisions and nudges.",
40
- "targets": ["ios", "android", "web"]
41
- },
42
- {
43
- "type": "fitness.dailyPlanCard",
44
- "description": "Goal-aware daily plan generated from approved health context.",
45
- "targets": ["ios", "android", "web"]
46
- },
47
- {
48
- "type": "fitness.goalEditorCard",
49
- "description": "Lets the user select one active fitness focus from approved Oomi goals.",
50
- "targets": ["ios", "android", "web"]
52
+ "targets": [
53
+ "ios",
54
+ "android",
55
+ "web"
56
+ ]
51
57
  },
52
58
  {
53
59
  "type": "fitness.goalChecklist",
54
60
  "description": "Checklist of suggested goals for today.",
55
- "targets": ["ios", "android", "web"]
61
+ "targets": [
62
+ "ios",
63
+ "android",
64
+ "web"
65
+ ]
56
66
  },
57
67
  {
58
68
  "type": "fitness.metricGrid",
59
69
  "description": "Grid of approved HealthKit or Health Connect activity, sleep, cardio, and body metrics.",
60
- "targets": ["ios", "android", "web"]
70
+ "targets": [
71
+ "ios",
72
+ "android",
73
+ "web"
74
+ ]
61
75
  },
62
76
  {
63
77
  "type": "fitness.workoutList",
64
78
  "description": "Recent workouts synced from the user's approved mobile health source.",
65
- "targets": ["ios", "android", "web"]
79
+ "targets": [
80
+ "ios",
81
+ "android",
82
+ "web"
83
+ ]
66
84
  }
67
85
  ],
68
86
  "actions": [
@@ -71,9 +89,13 @@
71
89
  "description": "Marks one fitness goal complete.",
72
90
  "payloadSchema": {
73
91
  "type": "object",
74
- "required": ["goalId"],
92
+ "required": [
93
+ "goalId"
94
+ ],
75
95
  "properties": {
76
- "goalId": { "type": "string" }
96
+ "goalId": {
97
+ "type": "string"
98
+ }
77
99
  }
78
100
  }
79
101
  },
@@ -82,22 +104,13 @@
82
104
  "description": "Records a completed workout and updates today's movement state.",
83
105
  "payloadSchema": {
84
106
  "type": "object",
85
- "properties": {
86
- "goalId": { "type": "string" },
87
- "minutes": { "type": "integer", "minimum": 1 }
88
- }
89
- }
90
- },
91
- {
92
- "id": "fitness.set_goal",
93
- "description": "Sets the user's active Fitness focus so Oomi can tune the mini-app and future nudges.",
94
- "payloadSchema": {
95
- "type": "object",
96
- "required": ["goalId"],
97
107
  "properties": {
98
108
  "goalId": {
99
- "type": "string",
100
- "enum": ["move_more", "sleep_better", "build_workout_habit"]
109
+ "type": "string"
110
+ },
111
+ "minutes": {
112
+ "type": "integer",
113
+ "minimum": 1
101
114
  }
102
115
  }
103
116
  }
@@ -109,6 +122,46 @@
109
122
  "healthkit.read",
110
123
  "health_connect.read"
111
124
  ]
125
+ },
126
+ {
127
+ "personaType": "finance",
128
+ "displayName": "Finance",
129
+ "components": [
130
+ {
131
+ "type": "finance.accountList",
132
+ "description": "Linked depository and credit accounts with current/available balances.",
133
+ "targets": [
134
+ "web"
135
+ ]
136
+ },
137
+ {
138
+ "type": "finance.dataSource",
139
+ "description": "Plaid sandbox link status for the Finance persona-app.",
140
+ "targets": [
141
+ "web"
142
+ ]
143
+ }
144
+ ],
145
+ "actions": [
146
+ {
147
+ "id": "finance.sync_balances",
148
+ "description": "Refresh balances for linked depository and credit accounts.",
149
+ "payloadSchema": {
150
+ "type": "object"
151
+ }
152
+ },
153
+ {
154
+ "id": "finance.reconnect",
155
+ "description": "Refresh Finance persona-app state after a Plaid update-mode reconnect.",
156
+ "payloadSchema": {
157
+ "type": "object"
158
+ }
159
+ }
160
+ ],
161
+ "permissions": [
162
+ "finance.balances.read",
163
+ "plaid.link"
164
+ ]
112
165
  }
113
166
  ]
114
167
  }
@@ -11,44 +11,25 @@ Use this skill when you need to:
11
11
  - inspect managed chat or voice health
12
12
  - inspect or update component-composed Oomi persona app state
13
13
  - read approved Oomi backend context such as HealthKit summaries synced by the mobile app
14
+ - read permissioned Finance balances (no tokens, routing, or transactions)
14
15
  - control the Oomi avatar with inline tags
15
16
 
16
17
  ## Primary Operator Workflow
17
18
 
18
19
  If the user wants OpenClaw connected to Oomi:
19
20
 
20
- 1. Ensure `oomi-ai` is installed or updated:
21
+ 1. For a first-time install or missing local pairing state, run the bootstrap exactly once using the requested app URL:
21
22
  ```bash
22
- pnpm add -g oomi-ai@latest
23
- ```
24
- Fallback:
25
- ```bash
26
- npm install -g oomi-ai@latest
27
- ```
28
-
29
- 2. Pair the device:
30
- ```bash
31
- oomi openclaw pair --app-url https://www.oomi.ai --no-start
32
- ```
33
-
34
- 3. Install the plugin:
35
- ```bash
36
- openclaw plugins install oomi-ai@latest
23
+ curl -fsSL https://www.oomi.ai/install.sh | bash
37
24
  ```
25
+ The bootstrap installs or updates `oomi-ai`, performs the one initial pair, installs the plugin, and starts the saved bridge detached. Do not pair again after it finishes.
38
26
 
39
- 4. Print config guidance:
27
+ 2. Print config guidance and apply the `channels.oomi.accounts.default` config:
40
28
  ```bash
41
29
  oomi openclaw plugin --show-secrets --backend-url https://api.oomi.ai
42
30
  ```
43
31
 
44
- 5. Apply the `channels.oomi.accounts.default` config and restart OpenClaw.
45
-
46
- 6. Refresh the running bridge after package updates:
47
- ```bash
48
- oomi openclaw refresh --skip-version-check
49
- ```
50
-
51
- 7. Start or repair the bridge:
32
+ 3. Restart OpenClaw, then ensure the saved bridge is running:
52
33
  ```bash
53
34
  oomi openclaw bridge ensure --detach
54
35
  ```
@@ -61,6 +42,20 @@ On macOS, prefer supervised mode:
61
42
  oomi openclaw bridge service install
62
43
  ```
63
44
 
45
+ 4. If the Auth Link printed by bootstrap is missing or expired, create a replacement without re-pairing:
46
+ ```bash
47
+ oomi openclaw invite --app-url https://www.oomi.ai
48
+ ```
49
+
50
+ 5. If the machine is already paired, do not rerun bootstrap. Refresh the installed package and plugin without changing pairing state, then use `invite`, `bridge ensure`, or `bridge restart` as needed:
51
+ ```bash
52
+ pnpm add -g oomi-ai@latest
53
+ openclaw plugins install oomi-ai@latest
54
+ oomi openclaw refresh --skip-version-check
55
+ ```
56
+
57
+ If the user provides a specific app URL, substitute it in both the bootstrap URL and the `invite --app-url` command.
58
+
64
59
  ## Health Checks
65
60
 
66
61
  Use these when chat or voice is failing:
@@ -101,25 +96,18 @@ Interpret bridge states like this:
101
96
  Component-composed persona apps are the default Oomi mini-app path. Use these commands when the user asks about or updates an existing native/web Oomi persona app such as Fitness Today:
102
97
 
103
98
  ```bash
104
- oomi fitness show --json
105
99
  oomi persona-apps list --json
106
100
  oomi persona-apps show fitness-today --json
107
101
  oomi persona-apps apply-action fitness-today --action fitness.complete_workout --payload-json '{"goalId":"easy-run","minutes":20}' --json
108
102
  ```
109
103
 
110
104
  Rules:
111
- - prefer `oomi fitness show --json` for Fitness questions because it combines mini-app state and approved health context
112
105
  - answer from returned `appState.bindings`, not from memory
113
106
  - apply updates through approved `persona-apps apply-action` actions
114
107
  - if the persona app does not exist, ask the user to add it from the Oomi client first so permissions and hydration can run
115
108
  - do not create local persona UI projects from the OpenClaw machine
116
109
  - do not execute persona UI jobs or start local persona app runtimes
117
110
 
118
- Supported Fitness actions include:
119
- - `fitness.set_goal` with `{"goalId":"move_more"}`, `{"goalId":"sleep_better"}`, or `{"goalId":"build_workout_habit"}`
120
- - `fitness.complete_goal`
121
- - `fitness.complete_workout`
122
-
123
111
  ## Permissioned Context Tools
124
112
 
125
113
  Use context commands before answering personal data questions that should come from Oomi-approved permissions.
@@ -139,6 +127,21 @@ Rules:
139
127
  - never present stale HealthKit values as current values
140
128
  - do not ask HealthKit directly; the Oomi mobile app syncs approved HealthKit data to the backend
141
129
 
130
+ ## Phone Notifications
131
+
132
+ To reach the user on their phone when they are not in the conversation:
133
+
134
+ ```bash
135
+ oomi notify "Your 7-day plan is ready. Want to look now?" --title "Fitness" --json
136
+ ```
137
+
138
+ Rules:
139
+ - notify only for things worth interrupting the user for (a reminder they asked for, a finished task, something time-sensitive); say everything else in chat
140
+ - keep it short and specific; the title defaults to your agent name
141
+ - tapping it opens the Oomi app to the conversation, so follow up in chat
142
+ - if `notification.status` is `skipped`, tell the user `notification.guidance` instead of retrying
143
+ - if rate limited, wait `retryAfterSeconds` before trying again
144
+
142
145
  ## Hidden Speech Payload
143
146
 
144
147
  Managed voice can carry a hidden TTS-only speech sidecar alongside the normal assistant message.
@@ -199,3 +202,7 @@ Supported inline tags include:
199
202
  - expressions: `[face:happy]`, `[face:sad]`, `[face:surprised]`, `[face:focused]`, `[face:gentle]`, `[face:thinking]`
200
203
  - gestures: `[gesture:nod]`, `[gesture:think]`, `[gesture:shrug]`, `[gesture:wave]`, `[gesture:bow]`
201
204
  - gaze: `[look:camera]`, `[look:left]`, `[look:right]`, `[look:up]`, `[look:down]`
205
+
206
+ ## Permissioned Finance Context
207
+
208
+ Finance is a specialist persona-app on signed-in web. It is not a connected-chat tool. Do not run `oomi context finance`. There is no `finance.context.read` contextTool until Gate A. If the user asks about bank balances in live chat, send them to the web Finance persona-app. Do not answer from bank data.
@@ -15,26 +15,23 @@ Aliases allowed (if needed): wave -> Waving, walk -> Walking, idle -> Idle, sit/
15
15
 
16
16
  If the user asks to connect OpenClaw to Oomi voice or chat:
17
17
 
18
- 1. Refresh bootstrap/update first:
18
+ 1. For a first-time install or missing local pairing state, run bootstrap exactly once using the requested app URL:
19
19
  - `curl -fsSL https://www.oomi.ai/install.sh | bash`
20
- 2. Ensure `oomi-ai` is installed (`pnpm add -g oomi-ai@latest`).
21
- - Fallback: `npm install -g oomi-ai@latest`
22
- 3. Pair device token without starting legacy bridge:
23
- - `oomi openclaw pair --app-url https://www.oomi.ai --no-start`
24
- 4. Install OpenClaw plugin:
25
- - `openclaw plugins install oomi-ai@latest`
26
- 5. Configure channel account (`channels.oomi.accounts.default`) from:
20
+ - Bootstrap installs or updates `oomi-ai`, performs the one initial pair, installs the plugin, and starts the saved bridge detached.
21
+ - Do not pair again after bootstrap.
22
+ 2. Configure channel account (`channels.oomi.accounts.default`) from:
27
23
  - `oomi openclaw plugin --show-secrets --backend-url https://api.oomi.ai`
28
- 6. Restart OpenClaw.
29
- 7. Ensure exactly one bridge worker is running (singleton handler):
24
+ 3. Restart OpenClaw.
25
+ 4. Ensure exactly one bridge worker is running (singleton handler):
30
26
  - `oomi openclaw refresh --skip-version-check`
31
27
  - `oomi openclaw bridge ensure --detach`
32
28
  - If stale/disconnected: `oomi openclaw bridge restart --detach`
33
29
  - On macOS, prefer supervised mode: `oomi openclaw bridge service install`
34
- 8. If user provides an app URL (for example "Connect yourself to Oomi. Use app URL <URL>."):
35
- - Use that URL directly in the pair command.
36
- - Example: `oomi openclaw pair --app-url <URL> --no-start`
37
- 9. Return this exact result format to the user:
30
+ 5. If the Auth Link printed by bootstrap is missing or expired, create a replacement without re-pairing:
31
+ - `oomi openclaw invite --app-url https://www.oomi.ai`
32
+ 6. If the user provides an app URL, substitute it in both the bootstrap URL and the `invite --app-url` command.
33
+ 7. If the machine is already paired, do not rerun bootstrap. Use `invite` for link recovery and `bridge ensure` or `bridge restart` for runtime recovery.
34
+ 8. Return this exact result format to the user:
38
35
  - `Oomi Connect Ready`
39
36
  - `Auth Link: ...`
40
37
  - No extra narrative text before or after those lines.
@@ -87,24 +84,17 @@ Oomi persona apps are component-composed client surfaces backed by approved temp
87
84
  Persona UI creation and rendering happen inside Oomi-managed systems. Use the Oomi API tools instead:
88
85
 
89
86
  ```bash
90
- oomi fitness show --json
91
87
  oomi persona-apps list --json
92
88
  oomi persona-apps show fitness-today --json
93
89
  oomi persona-apps apply-action fitness-today --action fitness.complete_workout --payload-json '{"goalId":"easy-run","minutes":20}' --json
94
90
  ```
95
91
 
96
92
  Rules:
97
- - for Fitness questions, prefer `oomi fitness show --json` first because it combines mini-app state and approved health context
98
93
  - answer from returned `appState.bindings`, not memory
99
94
  - apply updates only through approved `persona-apps apply-action` actions
100
95
  - if the persona app is missing, ask the user to add it from the Oomi client first so permissions and hydration can run
101
96
  - do not execute persona UI jobs or start local persona app runtimes
102
97
 
103
- Supported Fitness actions include:
104
- - `fitness.set_goal` with `{"goalId":"move_more"}`, `{"goalId":"sleep_better"}`, or `{"goalId":"build_workout_habit"}`
105
- - `fitness.complete_goal`
106
- - `fitness.complete_workout`
107
-
108
98
  ## Permissioned Context Tools
109
99
 
110
100
  Use Oomi context tools when the user asks about personal data that should come from mobile permissions instead of memory.