oomi-ai 0.2.1 → 0.2.3

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
@@ -14,55 +14,64 @@ npm install -g oomi-ai
14
14
 
15
15
  ## Usage
16
16
 
17
- Install agent instructions only:
17
+ Install as an OpenClaw channel extension (preferred architecture):
18
18
  ```
19
- oomi init
19
+ openclaw plugins install oomi-ai@latest
20
20
  ```
21
21
 
22
- Install agent instructions + Oomi skill:
22
+ This package now ships an OpenClaw channel plugin (`openclaw.plugin.json`) with channel id `oomi`.
23
+ Channel account config fields (`channels.oomi.accounts.<accountId>`):
24
+ - `backendUrl`
25
+ - `deviceToken`
26
+ - `defaultSessionKey` (optional, default `agent:main:webchat:channel:oomi`)
27
+ - `requestTimeoutMs` (optional)
28
+
29
+ Print plugin install/config guidance from local pair state:
23
30
  ```
24
- oomi openclaw install
31
+ oomi openclaw plugin
25
32
  ```
26
33
 
27
- Start managed gateway bridge (OpenClaw host -> Oomi broker):
34
+ Install agent instructions only:
28
35
  ```
29
- oomi openclaw bridge \
30
- --broker-http https://your-signaling-service.example.com \
31
- --broker-ws wss://your-signaling-service.example.com/cable \
32
- --pair-code ABCD2345
36
+ oomi init
33
37
  ```
34
38
 
35
- Bridge env config:
36
- - `OOMI_CHAT_BROKER_HTTP_URL`
37
- - `OOMI_CHAT_BROKER_DEVICE_WS_URL`
39
+ Install agent instructions + Oomi skill:
40
+ ```
41
+ oomi openclaw install
42
+ ```
38
43
 
39
- Pair + bootstrap bridge automatically from Oomi web backend:
44
+ Pair and provision device token from Oomi web backend:
40
45
  ```
41
- oomi openclaw pair --app-url https://your-oomi-app.vercel.app --device-id my-openclaw-mac --detach
46
+ oomi openclaw pair --app-url https://your-oomi-app.vercel.app --device-id my-openclaw-mac --no-start
42
47
  ```
43
48
 
44
49
  `--app-url` must be reachable from the OpenClaw host. If OpenClaw runs on a different machine/network, do not use `localhost` unless tunneled.
45
50
 
46
51
  This prints:
47
- - `Connect code: <device-id>`
48
- - `Connect URL: https://.../?connect=managed://<device-id>&sessionKey=...`
52
+ - `Auth invite URL: https://.../connect/<single-use-token>`
49
53
  - A copy-ready block for the user:
50
54
  - `Oomi Connect Ready`
51
- - `Code: ...`
52
- - `Link: ...`
55
+ - `Auth Link: ...`
56
+
57
+ If you need a fresh auth link later (without re-pairing), run:
58
+ ```
59
+ oomi openclaw invite --app-url https://your-oomi-app.vercel.app
60
+ ```
53
61
 
54
62
  Agent-intent mapping (recommended):
55
63
  - If user says `Connect yourself to Oomi. Use app URL https://www.oomi.ai.`
56
64
  - Run:
57
65
  - `curl -fsSL https://www.oomi.ai/install.sh | bash`
58
66
  - or `pnpm add -g oomi-ai@latest` (`npm install -g oomi-ai@latest` fallback)
59
- - `oomi openclaw pair --app-url https://www.oomi.ai --detach`
60
- - If already paired and reconnecting:
61
- - `oomi openclaw bridge --detach`
67
+ - `oomi openclaw pair --app-url https://www.oomi.ai --no-start`
68
+ - `openclaw plugins install oomi-ai@latest`
69
+ - `oomi openclaw plugin --show-secrets --backend-url https://api.oomi.ai`
70
+ - Apply `channels.oomi.accounts.default` config and restart OpenClaw.
62
71
 
63
72
  Important distinction:
64
73
  - `pairCode` is one-time and used internally by the pair/bootstrap flow.
65
- - `connect code` is the stable device id used by browser connect (`managed://<device-id>`).
74
+ - Invite auth links are the required user flow.
66
75
 
67
76
  Sync personas from the repo into the backend registry:
68
77
  ```
@@ -79,9 +88,7 @@ Optional flags:
79
88
  oomi init --workspace /path/to/openclaw/workspace
80
89
  oomi init --agents-file /path/to/AGENTS.md
81
90
  oomi openclaw install --skills-dir /path/to/openclaw/skills
82
- oomi openclaw bridge --broker-ws wss://.../cable --device-token <token>
83
91
  oomi openclaw pair --app-url https://your-oomi-app.vercel.app --no-start
84
- oomi openclaw pair --app-url https://your-oomi-app.vercel.app --detach
85
92
  oomi openclaw pair --app-url https://your-oomi-app.vercel.app --json
86
93
  oomi personas sync --root /path/to/oomi
87
94
  oomi personas create creator --status active --chat-session agent:main:webchat:channel:oomi-creator
@@ -19,16 +19,24 @@ If the user asks to connect OpenClaw to Oomi voice/web:
19
19
  - `curl -fsSL https://www.oomi.ai/install.sh | bash`
20
20
  2. Ensure `oomi-ai` is installed (`pnpm add -g oomi-ai@latest`).
21
21
  - Fallback: `npm install -g oomi-ai@latest`
22
- 3. Run:
23
- - `oomi openclaw pair --app-url https://www.oomi.ai --detach`
24
- 4. If already paired and reconnecting:
25
- - `oomi openclaw bridge --detach`
26
- 5. If user provides an app URL (for example "Connect yourself to Oomi. Use app URL <URL>."):
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:
27
+ - `oomi openclaw plugin --show-secrets --backend-url https://api.oomi.ai`
28
+ 6. Restart OpenClaw.
29
+ 7. If user provides an app URL (for example "Connect yourself to Oomi. Use app URL <URL>."):
27
30
  - Use that URL directly in the pair command.
28
- - Example: `oomi openclaw pair --app-url <URL> --detach`
29
- 6. Return this exact result format to the user:
31
+ - Example: `oomi openclaw pair --app-url <URL> --no-start`
32
+ 8. Return this exact result format to the user:
30
33
  - `Oomi Connect Ready`
31
- - `Code: ...`
32
- - `Link: ...`
34
+ - `Auth Link: ...`
35
+ - No extra narrative text before or after those lines.
36
+
37
+ Primary UX requirement:
38
+ - Never ask the user to enter raw pairing identifiers.
39
+ - The user should open the auth link, complete sign-in, and land directly in connected chat.
40
+ - Return `/connect/<invite-token>` auth links only.
33
41
 
34
42
  Do not ask users to paste gateway IP/token/password when managed connect is available.
package/bin/oomi-ai.js CHANGED
@@ -142,6 +142,12 @@ Commands:
142
142
  openclaw pair
143
143
  Pair this OpenClaw host with Oomi and start bridge (single command).
144
144
 
145
+ openclaw invite
146
+ Create a single-use auth invite link for the paired OpenClaw device.
147
+
148
+ openclaw plugin
149
+ Print OpenClaw extension install/config guidance for Oomi channel plugin.
150
+
145
151
  personas sync
146
152
  Sync personas from the repo into the Oomi backend registry.
147
153
 
@@ -155,13 +161,14 @@ Common flags:
155
161
  --broker-http URL Managed broker HTTPS URL (for pair claim)
156
162
  --broker-ws URL Managed broker device WS URL (wss://.../cable)
157
163
  --pair-code CODE One-time pairing code from Oomi
158
- --app-url URL Oomi app URL used for pairing APIs (default: http://127.0.0.1:3000)
164
+ --app-url URL Oomi app URL used for pairing APIs (default: http://127.0.0.1:3456)
159
165
  --label TEXT Pairing label shown in broker logs
160
166
  --session-key KEY Session key used in generated connect URL
161
167
  --detach Start bridge in background and exit
162
168
  --no-start Pair and save token, but do not start bridge
163
169
  --device-id ID Bridge device identifier (default: host name)
164
170
  --device-token TOKEN Existing bridge device token
171
+ --show-secrets Print full token values in diagnostic output
165
172
  --json Print pairing result as JSON (for automation)
166
173
  --backend-url URL Override Oomi backend URL
167
174
  --root PATH Override repo root path for persona discovery
@@ -591,6 +598,25 @@ async function requestManagedPairCode({ appUrl, label }) {
591
598
  return payload;
592
599
  }
593
600
 
601
+ async function requestConnectInviteLink({ backendHttp, appUrl, sessionKey, deviceToken }) {
602
+ const response = await fetch(`${backendHttp.replace(/\/$/, '')}/v1/invite_links/start`, {
603
+ method: 'POST',
604
+ headers: {
605
+ 'Content-Type': 'application/json',
606
+ Authorization: `Bearer ${deviceToken}`,
607
+ },
608
+ body: JSON.stringify({ appUrl, sessionKey }),
609
+ });
610
+ const payload = await response.json().catch(() => ({}));
611
+ if (!response.ok || !payload?.inviteUrl) {
612
+ const message =
613
+ (payload && typeof payload.error === 'string' && payload.error) ||
614
+ `Invite link start failed (${response.status})`;
615
+ throw new Error(message);
616
+ }
617
+ return payload;
618
+ }
619
+
594
620
  async function fetchManagedGatewayConfig({ appUrl }) {
595
621
  const baseUrl = appUrl.replace(/\/$/, '');
596
622
  const response = await fetch(`${baseUrl}/api/gateway/managed/config`, {
@@ -614,6 +640,17 @@ function injectGatewayAuth(frameText, gatewayAuth) {
614
640
  return frameText;
615
641
  }
616
642
  const params = frame.params && typeof frame.params === 'object' ? frame.params : {};
643
+ const existingScopes = Array.isArray(params.scopes)
644
+ ? params.scopes.filter((value) => typeof value === 'string' && value.trim())
645
+ : [];
646
+ const requiredScopes = ['operator.read', 'operator.write'];
647
+ for (const scope of requiredScopes) {
648
+ if (!existingScopes.includes(scope)) {
649
+ existingScopes.push(scope);
650
+ }
651
+ }
652
+ params.scopes = existingScopes;
653
+
617
654
  const auth = {};
618
655
  if (gatewayAuth.token) auth.token = gatewayAuth.token;
619
656
  else if (gatewayAuth.password) auth.password = gatewayAuth.password;
@@ -818,10 +855,19 @@ async function startOpenclawBridge(flags) {
818
855
  sendBrokerPayload(brokerSocket, { action: 'gateway_frame', type: 'gateway.frame', sessionId, frame });
819
856
  });
820
857
 
821
- gatewaySocket.on('close', () => {
822
- console.log(`[bridge] gateway.close ${sessionId}`);
858
+ gatewaySocket.on('close', (code, reason) => {
859
+ const reasonText = reason ? reason.toString() : '';
860
+ console.log(
861
+ `[bridge] gateway.close ${sessionId} code=${String(code)}${reasonText ? ` reason=${reasonText}` : ''}`
862
+ );
823
863
  activeGatewaySockets.delete(sessionId);
824
- sendBrokerPayload(brokerSocket, { action: 'gateway_closed', type: 'gateway.closed', sessionId });
864
+ sendBrokerPayload(brokerSocket, {
865
+ action: 'gateway_closed',
866
+ type: 'gateway.closed',
867
+ sessionId,
868
+ code,
869
+ reason: reasonText,
870
+ });
825
871
  });
826
872
 
827
873
  gatewaySocket.on('error', (err) => {
@@ -900,7 +946,7 @@ async function startOpenclawBridge(flags) {
900
946
 
901
947
  async function pairAndStartOpenclawBridge(flags) {
902
948
  const bridgeState = readBridgeState();
903
- const appUrl = String(flags['app-url'] || process.env.OOMI_APP_URL || 'http://127.0.0.1:3000').trim();
949
+ const appUrl = String(flags['app-url'] || process.env.OOMI_APP_URL || 'http://127.0.0.1:3456').trim();
904
950
  const deviceId = resolveDeviceId(flags, bridgeState);
905
951
  const label = String(flags.label || `${deviceId}-bridge`).trim();
906
952
  const sessionKey = String(
@@ -937,19 +983,24 @@ async function pairAndStartOpenclawBridge(flags) {
937
983
  brokerWs,
938
984
  deviceId,
939
985
  deviceToken,
986
+ sessionKey,
940
987
  claimedAt: new Date().toISOString(),
941
988
  expiresAt: claimed.expiresAt || null,
942
989
  });
943
990
 
944
- const connectCode = deviceId;
945
- const gatewayValue = `managed://${deviceId}`;
946
- const connectUrl = `${appUrl.replace(/\/$/, '')}/?connect=${encodeURIComponent(gatewayValue)}&sessionKey=${encodeURIComponent(sessionKey)}`;
991
+ const invite = await requestConnectInviteLink({
992
+ backendHttp: managedConfig.brokerHttpUrl,
993
+ appUrl,
994
+ sessionKey,
995
+ deviceToken,
996
+ });
997
+ const inviteUrl = String(invite.inviteUrl || '').trim();
998
+ const inviteExpiresAt = String(invite.expiresAt || '').trim();
947
999
 
948
1000
  const pairSummary = {
949
1001
  deviceId,
950
- connectCode,
951
- gatewayValue,
952
- connectUrl,
1002
+ inviteUrl,
1003
+ inviteExpiresAt,
953
1004
  brokerHttpUrl: managedConfig.brokerHttpUrl,
954
1005
  brokerDeviceWsUrl: brokerWs,
955
1006
  bridgeMode: shouldStart ? (detach ? 'detached' : 'attached') : 'skipped',
@@ -960,15 +1011,15 @@ async function pairAndStartOpenclawBridge(flags) {
960
1011
  console.log(JSON.stringify(pairSummary, null, 2));
961
1012
  } else {
962
1013
  console.log(`Paired device: ${deviceId}`);
963
- console.log(`Connect code: ${connectCode}`);
964
- console.log(`Gateway value: ${gatewayValue}`);
965
- console.log(`Connect URL: ${connectUrl}`);
1014
+ console.log(`Auth invite URL: ${inviteUrl}`);
1015
+ if (inviteExpiresAt) {
1016
+ console.log(`Invite expires at: ${inviteExpiresAt}`);
1017
+ }
966
1018
  console.log('');
967
- console.log('Share this with the user:');
1019
+ console.log('Share this exact response with the user (no extra text):');
968
1020
  console.log('----------------------------------------');
969
1021
  console.log('Oomi Connect Ready');
970
- console.log(`Code: ${connectCode}`);
971
- console.log(`Link: ${connectUrl}`);
1022
+ console.log(`Auth Link: ${inviteUrl}`);
972
1023
  console.log('----------------------------------------');
973
1024
  }
974
1025
 
@@ -1009,6 +1060,128 @@ async function pairAndStartOpenclawBridge(flags) {
1009
1060
  });
1010
1061
  }
1011
1062
 
1063
+ async function createOpenclawInviteLink(flags) {
1064
+ const bridgeState = readBridgeState();
1065
+ const backendHttp = String(
1066
+ flags['backend-url'] ||
1067
+ flags['broker-http'] ||
1068
+ process.env.OOMI_BACKEND_URL ||
1069
+ process.env.OOMI_CHAT_BROKER_HTTP_URL ||
1070
+ bridgeState.brokerHttp ||
1071
+ ''
1072
+ ).trim();
1073
+ const appUrl = String(flags['app-url'] || process.env.OOMI_APP_URL || 'http://127.0.0.1:3456').trim();
1074
+ const sessionKey = String(
1075
+ flags['session-key'] ||
1076
+ process.env.OOMI_SESSION_KEY ||
1077
+ bridgeState.sessionKey ||
1078
+ 'agent:main:webchat:channel:oomi'
1079
+ ).trim();
1080
+ const deviceToken = String(flags['device-token'] || bridgeState.deviceToken || '').trim();
1081
+ const jsonOutput = isTruthyFlag(flags.json);
1082
+
1083
+ if (!backendHttp) {
1084
+ throw new Error('Missing backend URL. Set --backend-url (or --broker-http) or pair first.');
1085
+ }
1086
+ if (!deviceToken) {
1087
+ throw new Error('Missing device token in bridge state. Run: oomi openclaw pair --app-url https://www.oomi.ai --no-start');
1088
+ }
1089
+
1090
+ const invite = await requestConnectInviteLink({
1091
+ backendHttp,
1092
+ appUrl,
1093
+ sessionKey,
1094
+ deviceToken,
1095
+ });
1096
+
1097
+ const summary = {
1098
+ appUrl,
1099
+ backendHttp,
1100
+ inviteUrl: invite.inviteUrl,
1101
+ expiresAt: invite.expiresAt || null,
1102
+ sessionKey,
1103
+ };
1104
+
1105
+ if (jsonOutput) {
1106
+ console.log(JSON.stringify(summary, null, 2));
1107
+ return;
1108
+ }
1109
+
1110
+ console.log('Oomi Auth Invite Ready');
1111
+ console.log('----------------------');
1112
+ console.log(`Auth Link: ${summary.inviteUrl}`);
1113
+ if (summary.expiresAt) {
1114
+ console.log(`Expires: ${summary.expiresAt}`);
1115
+ }
1116
+ }
1117
+
1118
+ function printOpenclawPluginSetup(flags) {
1119
+ const bridgeState = readBridgeState();
1120
+ const backendUrl = String(
1121
+ flags['backend-url'] ||
1122
+ process.env.OOMI_BACKEND_URL ||
1123
+ process.env.OOMI_CHAT_BROKER_HTTP_URL ||
1124
+ bridgeState.brokerHttp ||
1125
+ ''
1126
+ ).trim();
1127
+ const deviceToken = String(
1128
+ flags['device-token'] ||
1129
+ bridgeState.deviceToken ||
1130
+ ''
1131
+ ).trim();
1132
+ const showSecrets = isTruthyFlag(flags['show-secrets']);
1133
+ const redactToken = (value) => {
1134
+ if (!value) return '';
1135
+ if (showSecrets) return value;
1136
+ if (value.length <= 12) return '***';
1137
+ return `${value.slice(0, 6)}...${value.slice(-6)}`;
1138
+ };
1139
+ const defaultSessionKey = String(
1140
+ flags['session-key'] ||
1141
+ process.env.OOMI_SESSION_KEY ||
1142
+ 'agent:main:webchat:channel:oomi'
1143
+ ).trim();
1144
+
1145
+ console.log('OpenClaw Oomi Plugin Setup');
1146
+ console.log('--------------------------');
1147
+ console.log('1) Install extension package in OpenClaw:');
1148
+ console.log(' openclaw plugins install oomi-ai@latest');
1149
+ console.log('');
1150
+ console.log('2) Configure OpenClaw channel account (channels.oomi.accounts.default):');
1151
+ console.log(
1152
+ JSON.stringify(
1153
+ {
1154
+ channels: {
1155
+ oomi: {
1156
+ defaultAccountId: 'default',
1157
+ accounts: {
1158
+ default: {
1159
+ enabled: true,
1160
+ backendUrl,
1161
+ deviceToken: redactToken(deviceToken),
1162
+ defaultSessionKey,
1163
+ },
1164
+ },
1165
+ },
1166
+ },
1167
+ },
1168
+ null,
1169
+ 2
1170
+ )
1171
+ );
1172
+ if (deviceToken && !showSecrets) {
1173
+ console.log('Token is redacted by default. Use --show-secrets to print full values.');
1174
+ console.log(`Bridge state file: ${resolveBridgeStatePath()}`);
1175
+ }
1176
+ console.log('');
1177
+
1178
+ if (!backendUrl || !deviceToken) {
1179
+ console.log('Missing backend/device credentials in local state.');
1180
+ console.log('Run: oomi openclaw pair --app-url https://www.oomi.ai --no-start');
1181
+ console.log('Then run: oomi openclaw plugin');
1182
+ }
1183
+ }
1184
+
1012
1185
  async function main() {
1013
1186
  const args = parseArgs(process.argv);
1014
1187
  const command = args.command;
@@ -1050,6 +1223,16 @@ async function main() {
1050
1223
  return;
1051
1224
  }
1052
1225
 
1226
+ if (command === 'openclaw' && subcommand === 'invite') {
1227
+ await createOpenclawInviteLink(args.flags);
1228
+ return;
1229
+ }
1230
+
1231
+ if (command === 'openclaw' && subcommand === 'plugin') {
1232
+ printOpenclawPluginSetup(args.flags);
1233
+ return;
1234
+ }
1235
+
1053
1236
  if (command === 'personas' && subcommand === 'sync') {
1054
1237
  await syncPersonas({ backendUrl: args.flags['backend-url'], root: args.flags.root });
1055
1238
  return;
@@ -0,0 +1,247 @@
1
+ const CHANNEL_ID = 'oomi';
2
+ const DEFAULT_SESSION_KEY = 'agent:main:webchat:channel:oomi';
3
+ const DEFAULT_TIMEOUT_MS = 15000;
4
+
5
+ function toString(value, fallback = '') {
6
+ return typeof value === 'string' && value.trim() ? value.trim() : fallback;
7
+ }
8
+
9
+ function toNumber(value, fallback, { min = 1, max = Number.MAX_SAFE_INTEGER } = {}) {
10
+ if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
11
+ const normalized = Math.floor(value);
12
+ if (normalized < min) return fallback;
13
+ if (normalized > max) return max;
14
+ return normalized;
15
+ }
16
+
17
+ function parseAccounts(rawAccounts) {
18
+ if (!rawAccounts || typeof rawAccounts !== 'object') return {};
19
+ const accounts = {};
20
+
21
+ for (const [accountId, raw] of Object.entries(rawAccounts)) {
22
+ if (!raw || typeof raw !== 'object') continue;
23
+ accounts[accountId] = {
24
+ enabled: raw.enabled !== false,
25
+ backendUrl: toString(raw.backendUrl),
26
+ deviceToken: toString(raw.deviceToken),
27
+ defaultSessionKey: toString(raw.defaultSessionKey, DEFAULT_SESSION_KEY),
28
+ requestTimeoutMs: toNumber(raw.requestTimeoutMs, DEFAULT_TIMEOUT_MS, { min: 2000, max: 120000 }),
29
+ };
30
+ }
31
+
32
+ return accounts;
33
+ }
34
+
35
+ function normalizeConfig(cfg = {}) {
36
+ const channelConfig = cfg?.channels?.[CHANNEL_ID] || {};
37
+ const configuredAccounts = parseAccounts(channelConfig.accounts);
38
+ const accountIds = Object.keys(configuredAccounts);
39
+ const defaultAccountId = toString(channelConfig.defaultAccountId, accountIds[0] || 'default');
40
+
41
+ if (!configuredAccounts[defaultAccountId]) {
42
+ configuredAccounts[defaultAccountId] = {
43
+ enabled: true,
44
+ backendUrl: '',
45
+ deviceToken: '',
46
+ defaultSessionKey: DEFAULT_SESSION_KEY,
47
+ requestTimeoutMs: DEFAULT_TIMEOUT_MS,
48
+ };
49
+ }
50
+
51
+ return {
52
+ defaultAccountId,
53
+ accounts: configuredAccounts,
54
+ };
55
+ }
56
+
57
+ function resolveAccount(cfg, accountId) {
58
+ const normalized = normalizeConfig(cfg);
59
+ const resolvedId = toString(accountId, normalized.defaultAccountId);
60
+ const account = normalized.accounts[resolvedId];
61
+ if (!account) {
62
+ return {
63
+ accountId: resolvedId,
64
+ account: null,
65
+ };
66
+ }
67
+
68
+ return {
69
+ accountId: resolvedId,
70
+ account,
71
+ };
72
+ }
73
+
74
+ function extractText(payload) {
75
+ if (!payload) return '';
76
+ if (typeof payload === 'string') return payload.trim();
77
+
78
+ const direct = [payload.text, payload.message, payload.content, payload.body];
79
+ for (const value of direct) {
80
+ if (typeof value === 'string' && value.trim()) return value.trim();
81
+ }
82
+
83
+ if (Array.isArray(payload.content)) {
84
+ return payload.content
85
+ .filter((part) => part && typeof part === 'object' && part.type === 'text' && typeof part.text === 'string')
86
+ .map((part) => part.text.trim())
87
+ .filter(Boolean)
88
+ .join('\n');
89
+ }
90
+
91
+ return '';
92
+ }
93
+
94
+ function extractConversationKey(payload) {
95
+ const candidates = [
96
+ payload?.conversationKey,
97
+ payload?.threadId,
98
+ payload?.target?.conversationKey,
99
+ payload?.target?.threadId,
100
+ payload?.target?.id,
101
+ payload?.metadata?.conversationKey,
102
+ payload?.metadata?.threadId,
103
+ ];
104
+
105
+ for (const candidate of candidates) {
106
+ const value = toString(candidate);
107
+ if (value) return value;
108
+ }
109
+
110
+ return '';
111
+ }
112
+
113
+ function extractUserId(payload) {
114
+ const candidates = [
115
+ payload?.userId,
116
+ payload?.target?.userId,
117
+ payload?.metadata?.userId,
118
+ ];
119
+
120
+ for (const candidate of candidates) {
121
+ const value = toString(candidate);
122
+ if (value) return value;
123
+ }
124
+
125
+ return '';
126
+ }
127
+
128
+ async function postJson({ url, token, body, timeoutMs }) {
129
+ const controller = new AbortController();
130
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
131
+
132
+ try {
133
+ const response = await fetch(url, {
134
+ method: 'POST',
135
+ headers: {
136
+ 'Content-Type': 'application/json',
137
+ Authorization: `Bearer ${token}`,
138
+ },
139
+ body: JSON.stringify(body),
140
+ signal: controller.signal,
141
+ });
142
+
143
+ const payload = await response.json().catch(() => ({}));
144
+ return {
145
+ ok: response.ok,
146
+ status: response.status,
147
+ payload,
148
+ };
149
+ } finally {
150
+ clearTimeout(timeout);
151
+ }
152
+ }
153
+
154
+ const oomiChannelPlugin = {
155
+ id: CHANNEL_ID,
156
+ meta: {
157
+ name: 'Oomi',
158
+ description: 'Managed Oomi channel plugin.',
159
+ },
160
+ capabilities: {
161
+ chatTypes: ['direct'],
162
+ media: {
163
+ images: false,
164
+ audio: false,
165
+ files: false,
166
+ },
167
+ threads: true,
168
+ },
169
+
170
+ config(cfg) {
171
+ return normalizeConfig(cfg);
172
+ },
173
+
174
+ listAccountIds(cfg) {
175
+ const normalized = normalizeConfig(cfg);
176
+ return Object.entries(normalized.accounts)
177
+ .filter(([, account]) => account.enabled !== false)
178
+ .map(([accountId]) => accountId);
179
+ },
180
+
181
+ outbound: {
182
+ deliveryMode: 'direct',
183
+
184
+ async sendText(payload = {}) {
185
+ const { cfg, accountId } = payload;
186
+ const { accountId: resolvedAccountId, account } = resolveAccount(cfg, accountId);
187
+
188
+ if (!account || account.enabled === false) {
189
+ return {
190
+ ok: false,
191
+ error: `oomi account is disabled or missing (${resolvedAccountId})`,
192
+ };
193
+ }
194
+ if (!account.backendUrl || !account.deviceToken) {
195
+ return {
196
+ ok: false,
197
+ error: `oomi account is missing backendUrl/deviceToken (${resolvedAccountId})`,
198
+ };
199
+ }
200
+
201
+ const content = extractText(payload);
202
+ if (!content) {
203
+ return {
204
+ ok: false,
205
+ error: 'oomi outbound message content is empty',
206
+ };
207
+ }
208
+
209
+ const conversationKey = extractConversationKey(payload);
210
+ const userId = extractUserId(payload);
211
+ const sessionKey = toString(payload?.sessionKey || payload?.metadata?.sessionKey, account.defaultSessionKey);
212
+
213
+ const response = await postJson({
214
+ url: `${account.backendUrl}/v1/channel/plugin/messages`,
215
+ token: account.deviceToken,
216
+ timeoutMs: account.requestTimeoutMs,
217
+ body: {
218
+ conversationKey,
219
+ userId,
220
+ sessionKey,
221
+ content,
222
+ source: 'openclaw.channel',
223
+ metadata: {
224
+ accountId: resolvedAccountId,
225
+ },
226
+ },
227
+ });
228
+
229
+ if (!response.ok) {
230
+ const reason = toString(response.payload?.error, `status ${response.status}`);
231
+ return {
232
+ ok: false,
233
+ error: `oomi plugin message publish failed: ${reason}`,
234
+ };
235
+ }
236
+
237
+ return {
238
+ ok: true,
239
+ providerMessageId: toString(response.payload?.message?.messageId),
240
+ };
241
+ },
242
+ },
243
+ };
244
+
245
+ export default function register(api) {
246
+ api.registerChannel({ plugin: oomiChannelPlugin });
247
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "id": "oomi-ai",
3
+ "name": "Oomi Channel Plugin",
4
+ "description": "Managed Oomi channel integration for OpenClaw.",
5
+ "version": "0.2.1",
6
+ "author": "Oomi",
7
+ "license": "MIT",
8
+ "openclawVersion": ">=0.5.0",
9
+ "channels": [
10
+ "oomi"
11
+ ],
12
+ "configSchema": {
13
+ "type": "object",
14
+ "additionalProperties": false,
15
+ "properties": {}
16
+ }
17
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oomi-ai",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Oomi CLI for OpenClaw setup",
5
5
  "bin": {
6
6
  "oomi": "bin/oomi-ai.js"
@@ -9,6 +9,26 @@
9
9
  "engines": {
10
10
  "node": ">=18"
11
11
  },
12
+ "openclaw": {
13
+ "extensions": [
14
+ "./openclaw.extension.js"
15
+ ],
16
+ "channel": {
17
+ "id": "oomi",
18
+ "label": "Oomi",
19
+ "selectionLabel": "Oomi (Managed)",
20
+ "docsPath": "/channels/oomi",
21
+ "docsLabel": "oomi",
22
+ "blurb": "Managed channel transport for Oomi chat.",
23
+ "aliases": [
24
+ "oomi-ai"
25
+ ]
26
+ },
27
+ "install": {
28
+ "npmSpec": "oomi-ai",
29
+ "defaultChoice": "npm"
30
+ }
31
+ },
12
32
  "keywords": [
13
33
  "oomi",
14
34
  "openclaw",
@@ -19,7 +39,7 @@
19
39
  "homepage": "https://oomi.ai",
20
40
  "repository": {
21
41
  "type": "git",
22
- "url": "https://github.com/crispcode-io/oomi.git",
42
+ "url": "git+https://github.com/crispcode-io/oomi.git",
23
43
  "directory": "packages/oomi-ai"
24
44
  },
25
45
  "bugs": {
@@ -34,6 +54,8 @@
34
54
  "license": "MIT",
35
55
  "files": [
36
56
  "bin/oomi-ai.js",
57
+ "openclaw.plugin.json",
58
+ "openclaw.extension.js",
37
59
  "agent_instructions.md",
38
60
  "README.md",
39
61
  "skills/oomi"
@@ -19,16 +19,24 @@ If the user asks to connect OpenClaw to Oomi voice/web:
19
19
  - `curl -fsSL https://www.oomi.ai/install.sh | bash`
20
20
  2. Ensure `oomi-ai` is installed (`pnpm add -g oomi-ai@latest`).
21
21
  - Fallback: `npm install -g oomi-ai@latest`
22
- 3. Run:
23
- - `oomi openclaw pair --app-url https://www.oomi.ai --detach`
24
- 4. If already paired and reconnecting:
25
- - `oomi openclaw bridge --detach`
26
- 5. If user provides an app URL (for example "Connect yourself to Oomi. Use app URL <URL>."):
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:
27
+ - `oomi openclaw plugin --show-secrets --backend-url https://api.oomi.ai`
28
+ 6. Restart OpenClaw.
29
+ 7. If user provides an app URL (for example "Connect yourself to Oomi. Use app URL <URL>."):
27
30
  - Use that URL directly in the pair command.
28
- - Example: `oomi openclaw pair --app-url <URL> --detach`
29
- 6. Return this exact result format to the user:
31
+ - Example: `oomi openclaw pair --app-url <URL> --no-start`
32
+ 8. Return this exact result format to the user:
30
33
  - `Oomi Connect Ready`
31
- - `Code: ...`
32
- - `Link: ...`
34
+ - `Auth Link: ...`
35
+ - No extra narrative text before or after those lines.
36
+
37
+ Primary UX requirement:
38
+ - Never ask the user to enter raw pairing identifiers.
39
+ - The user should open the auth link, complete sign-in, and land directly in connected chat.
40
+ - Return `/connect/<invite-token>` auth links only.
33
41
 
34
42
  Do not ask users to paste gateway IP/token/password when managed connect is available.