oomi-ai 0.3.5 → 0.3.6

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
 
package/bin/oomi-ai.js CHANGED
@@ -233,9 +233,6 @@ 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]
@@ -382,18 +379,6 @@ function findRepoRoot(startDir) {
382
379
  return null;
383
380
  }
384
381
 
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
382
  function resolveSkillSource(cliRoot) {
398
383
  const packaged = path.join(PACKAGE_ROOT, 'skills', 'oomi');
399
384
  if (fs.existsSync(packaged)) {
@@ -587,32 +572,8 @@ async function handleContextHealthCommand(flags = {}) {
587
572
  printStructuredResult(payload, isTruthyFlag(flags.json));
588
573
  }
589
574
 
590
- async function handleFitnessShowCommand(flags = {}) {
591
- 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
- }
609
-
610
- printStructuredResult({
611
- ok: true,
612
- personaApp: appPayload?.personaApp || null,
613
- healthContext: healthPayload?.healthContext || null,
614
- healthContextError,
615
- }, isTruthyFlag(flags.json));
575
+ async function handleContextFinanceCommand(_flags = {}) {
576
+ 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
577
  }
617
578
 
618
579
  async function handleContextHealthSyncCommand(flags = {}) {
@@ -772,6 +733,28 @@ function readOpenclawGatewayConfig() {
772
733
  };
773
734
  }
774
735
 
736
+ function readOpenclawDefaultSessionKey() {
737
+ try {
738
+ const configPath = resolveOpenclawConfigPath();
739
+ if (!configPath || !fs.existsSync(configPath)) {
740
+ return '';
741
+ }
742
+
743
+ const parsed = JSON.parse(readFile(configPath));
744
+ const channels = parsed.channels || {};
745
+ const oomi = channels.oomi || {};
746
+ const accounts = oomi.accounts || {};
747
+ const defaultAccount = accounts.default || {};
748
+ const defaultSessionKey = typeof defaultAccount.defaultSessionKey === 'string'
749
+ ? defaultAccount.defaultSessionKey.trim()
750
+ : '';
751
+
752
+ return defaultSessionKey;
753
+ } catch {
754
+ return '';
755
+ }
756
+ }
757
+
775
758
  function resolveBridgeStatePath() {
776
759
  return resolveOpenclawBridgeStatePath();
777
760
  }
@@ -1080,6 +1063,25 @@ async function requestManagedPairCode({ appUrl, label }) {
1080
1063
  return payload;
1081
1064
  }
1082
1065
 
1066
+ async function requestBrokerPairCode({ brokerHttp, adminKey, label }) {
1067
+ const response = await fetch(`${brokerHttp.replace(/\/$/, '')}/v1/pair/start`, {
1068
+ method: 'POST',
1069
+ headers: {
1070
+ 'Content-Type': 'application/json',
1071
+ Authorization: `Bearer ${adminKey}`,
1072
+ },
1073
+ body: JSON.stringify({ label }),
1074
+ });
1075
+ const payload = await response.json().catch(() => ({}));
1076
+ if (!response.ok || !payload?.pairCode) {
1077
+ const message =
1078
+ (payload && typeof payload.error === 'string' && payload.error) ||
1079
+ `Broker pair start failed (${response.status})`;
1080
+ throw new Error(message);
1081
+ }
1082
+ return payload;
1083
+ }
1084
+
1083
1085
  async function requestConnectInviteLink({ backendHttp, appUrl, sessionKey, deviceToken }) {
1084
1086
  const response = await fetch(`${backendHttp.replace(/\/$/, '')}/v1/invite_links/start`, {
1085
1087
  method: 'POST',
@@ -1115,6 +1117,35 @@ async function fetchManagedGatewayConfig({ appUrl }) {
1115
1117
  return payload;
1116
1118
  }
1117
1119
 
1120
+ function shouldRewriteLoopbackBrokerForAppHost(appHostname) {
1121
+ return String(appHostname || '').trim().toLowerCase() === 'host.docker.internal';
1122
+ }
1123
+
1124
+ function isLoopbackBrokerHostname(hostname) {
1125
+ const normalized = String(hostname || '').trim().toLowerCase();
1126
+ return normalized === '127.0.0.1' || normalized === 'localhost' || normalized === '::1';
1127
+ }
1128
+
1129
+ function rewriteLoopbackBrokerUrlForAppHost(rawUrl, appUrl) {
1130
+ const value = String(rawUrl || '').trim();
1131
+ if (!value) return value;
1132
+
1133
+ try {
1134
+ const brokerUrl = new URL(value);
1135
+ const app = new URL(String(appUrl || '').trim());
1136
+ if (!isLoopbackBrokerHostname(brokerUrl.hostname)) {
1137
+ return value;
1138
+ }
1139
+ if (!shouldRewriteLoopbackBrokerForAppHost(app.hostname)) {
1140
+ return value;
1141
+ }
1142
+ brokerUrl.hostname = app.hostname;
1143
+ return brokerUrl.toString();
1144
+ } catch {
1145
+ return value;
1146
+ }
1147
+ }
1148
+
1118
1149
  function base64UrlEncode(value) {
1119
1150
  return Buffer.from(value)
1120
1151
  .toString('base64')
@@ -1773,48 +1804,6 @@ function printAssistantFinalDebugResult(result, asJson) {
1773
1804
  }
1774
1805
  }
1775
1806
 
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
1807
  async function handleOpenclawDebugCommand(action, flags) {
1819
1808
  const normalizedAction = String(action || '').trim().toLowerCase();
1820
1809
  const frameFile =
@@ -2558,11 +2547,13 @@ async function startOpenclawBridge(flags) {
2558
2547
  if (!deviceToken) {
2559
2548
  throw new Error('Broker pair claim did not return deviceToken.');
2560
2549
  }
2550
+ const configSessionKey = readOpenclawDefaultSessionKey();
2561
2551
  writeBridgeState({
2562
2552
  brokerHttp,
2563
2553
  brokerWs,
2564
2554
  deviceId,
2565
2555
  deviceToken,
2556
+ sessionKey: configSessionKey || 'agent:main:webchat:channel:oomi',
2566
2557
  claimedAt: new Date().toISOString(),
2567
2558
  expiresAt: claimed.expiresAt || null,
2568
2559
  });
@@ -3837,7 +3828,24 @@ async function pairAndStartOpenclawBridge(flags) {
3837
3828
 
3838
3829
  console.log(`Pairing OpenClaw host with Oomi app: ${appUrl}`);
3839
3830
  const managedConfig = await fetchManagedGatewayConfig({ appUrl });
3840
- const pairStarted = await requestManagedPairCode({ appUrl, label });
3831
+ const brokerHttp = rewriteLoopbackBrokerUrlForAppHost(managedConfig.brokerHttpUrl, appUrl);
3832
+ let pairStarted;
3833
+ try {
3834
+ pairStarted = await requestManagedPairCode({ appUrl, label });
3835
+ } catch (err) {
3836
+ const brokerAdminKey = String(process.env.OOMI_CHAT_BROKER_ADMIN_KEY || '').trim();
3837
+ if (!brokerAdminKey) {
3838
+ throw err;
3839
+ }
3840
+ console.warn(
3841
+ `[oomi] Managed pair start via app failed (${extractErrorMessage(err)}); falling back to direct broker pair/start.`
3842
+ );
3843
+ pairStarted = await requestBrokerPairCode({
3844
+ brokerHttp,
3845
+ adminKey: brokerAdminKey,
3846
+ label,
3847
+ });
3848
+ }
3841
3849
  const pairCode = String(pairStarted.pairCode || '').trim().toUpperCase();
3842
3850
  if (!pairCode) {
3843
3851
  throw new Error('Pairing code missing from backend response.');
@@ -3845,7 +3853,7 @@ async function pairAndStartOpenclawBridge(flags) {
3845
3853
 
3846
3854
  console.log(`Received pair code: ${pairCode}`);
3847
3855
  const claimed = await claimBridgeDeviceToken({
3848
- brokerHttp: managedConfig.brokerHttpUrl,
3856
+ brokerHttp,
3849
3857
  pairCode,
3850
3858
  deviceId,
3851
3859
  });
@@ -3854,9 +3862,11 @@ async function pairAndStartOpenclawBridge(flags) {
3854
3862
  throw new Error('Broker pair claim did not return deviceToken.');
3855
3863
  }
3856
3864
 
3857
- const brokerWs = String(flags['broker-ws'] || managedConfig.brokerDeviceWsUrl).trim();
3865
+ const brokerWs = String(
3866
+ flags['broker-ws'] || rewriteLoopbackBrokerUrlForAppHost(managedConfig.brokerDeviceWsUrl, appUrl)
3867
+ ).trim();
3858
3868
  writeBridgeState({
3859
- brokerHttp: managedConfig.brokerHttpUrl,
3869
+ brokerHttp,
3860
3870
  brokerWs,
3861
3871
  deviceId,
3862
3872
  deviceToken,
@@ -3866,7 +3876,7 @@ async function pairAndStartOpenclawBridge(flags) {
3866
3876
  });
3867
3877
 
3868
3878
  const invite = await requestConnectInviteLink({
3869
- backendHttp: managedConfig.brokerHttpUrl,
3879
+ backendHttp: brokerHttp,
3870
3880
  appUrl,
3871
3881
  sessionKey,
3872
3882
  deviceToken,
@@ -3940,9 +3950,11 @@ async function createOpenclawInviteLink(flags) {
3940
3950
  ''
3941
3951
  ).trim();
3942
3952
  const appUrl = String(flags['app-url'] || process.env.OOMI_APP_URL || 'http://127.0.0.1:3456').trim();
3953
+ const configSessionKey = readOpenclawDefaultSessionKey();
3943
3954
  const sessionKey = String(
3944
3955
  flags['session-key'] ||
3945
3956
  process.env.OOMI_SESSION_KEY ||
3957
+ configSessionKey ||
3946
3958
  bridgeState.sessionKey ||
3947
3959
  'agent:main:webchat:channel:oomi'
3948
3960
  ).trim();
@@ -4403,11 +4415,6 @@ async function main() {
4403
4415
  return;
4404
4416
  }
4405
4417
 
4406
- if (command === 'fitness' && subcommand === 'show') {
4407
- await handleFitnessShowCommand(args.flags);
4408
- return;
4409
- }
4410
-
4411
4418
  if (command === 'context' && subcommand === 'health') {
4412
4419
  if (args.positionals[0] === 'sync') {
4413
4420
  await handleContextHealthSyncCommand(args.flags);
@@ -4417,6 +4424,11 @@ async function main() {
4417
4424
  return;
4418
4425
  }
4419
4426
 
4427
+ if (command === 'context' && subcommand === 'finance') {
4428
+ await handleContextFinanceCommand(args.flags);
4429
+ return;
4430
+ }
4431
+
4420
4432
  console.error(`Unknown command: ${command} ${subcommand || ''}`.trim());
4421
4433
  usage();
4422
4434
  process.exit(1);
@@ -4457,4 +4469,5 @@ export {
4457
4469
  isGatewayRunStartedFrame,
4458
4470
  isBridgeWorkerCommand,
4459
4471
  parsePositiveInteger,
4472
+ rewriteLoopbackBrokerUrlForAppHost,
4460
4473
  };
@@ -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
  } = {}) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oomi-ai",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
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.
@@ -199,3 +187,7 @@ Supported inline tags include:
199
187
  - expressions: `[face:happy]`, `[face:sad]`, `[face:surprised]`, `[face:focused]`, `[face:gentle]`, `[face:thinking]`
200
188
  - gestures: `[gesture:nod]`, `[gesture:think]`, `[gesture:shrug]`, `[gesture:wave]`, `[gesture:bow]`
201
189
  - gaze: `[look:camera]`, `[look:left]`, `[look:right]`, `[look:up]`, `[look:down]`
190
+
191
+ ## Permissioned Finance Context
192
+
193
+ 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.