@byokit/openclaw 0.18.3 → 0.20.0

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/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.20.0 (2026-10-19)
6
+
7
+ - FEAT: `ensureHelper(member, helper, { account, persona })` gives a member several helpers, each with its own persona and its own sessions (`agent:<member>:h-<helper>:…`) on one account the member already signed in, the API-key lane included: runs go to the engine agent already holding that account, so no credential is copied and no sign-in starts. `run({ helper })` speaks as that helper with its persona as the system text; `RunRef.helper` names it on `ToolHost.gate` and `call`, and its approvals stay the member's.
8
+ - SECURITY: helper requests fail closed with a typed `HelperError` before any engine request: an invalid id or persona, caller `system` text on a helper run, an account the member does not hold, a removed account (never moved to another sign-in), a session key outside the helper, a helper key run without `helper`, or `move` of a helper key. A helper gains no tool, builtin or session-key prefix the member's run would not.
9
+
10
+ ## 0.19.0 (2026-10-19)
11
+
12
+ - FEAT: connect Composio through the pinned engine's native MCP OAuth flow, with browser URL handoff and native sign-out.
13
+
5
14
  ## 0.18.3 (2026-10-19)
6
15
 
7
16
  - A signed-in Claude Code login is an account in the kit's account list, and its Claude CLI route's models are listed under it.
package/README.md CHANGED
@@ -390,6 +390,8 @@ apps can offer them when the app or person opts in, labelled API key (billed per
390
390
  `local`) so the app can label what is billed. Proxy routes, compatibility aliases, local runtimes stay off. Native Claude Code sign-in is offered by default. The kit re-checks the installed Claude login at each setup detection and, once it is signed in, restarts the engine onto it. A plan key (OpenCode Go: `kit.addAccount(member, { authChoice: 'opencode-go' }, on)`) asks for `Plan key`; `paste` it and the engine live-tests it before `done`. A paired device starts the same sign-in with either `via` and pastes the key over the sealed link, which keeps it on the host. A run reaches the pinned engine's static Go models (default `opencode-go/deepseek-v4-pro`); the kit keeps OpenCode Go's `x-opencode-session` header on every request, the setup probe included. A live-only Go model such as Muse needs no app config: the kit registers the plan's live catalog with each model's API, so `run({ model: 'opencode-go/muse-spark-1.3-contributor' })` runs on the account (or with `auth: 'apiKey'` on a Go key); an app that sets `config.models.providers['opencode-go'].models` keeps its own list. A choice the pinned Gateway cannot set up has readiness `no_upstream_flow` and is never offered. Pass the accounts the app offers in `KitOptions.offered` (the provider, auth choice or alias each route carries, e.g. `'openrouter'`); the kit allow-lists the plugin of every route those name, so an opted-in sign-in is not blocked. An app can still name a plugin directly in `config.plugins.allow`, and pass the route's `choice` explicitly to `kit.signIn()`.
391
391
  Offered provider plugins are allowed by default alongside the app's plugin ids. A route whose provider plugin ships outside the engine (Z.AI's GLM Coding Plan, for one) has readiness `needs_plugin` until the app calls `await kit.installPlugin(route.needs.plugin)`: it installs that plugin into the kit's own engine at exactly the engine's version and pinned npm integrity, keeps it allowed across restarts, and rejects `PluginInstallError` (`code` `version` or `integrity`) with nothing installed on a mismatch. Discovery never installs.
392
392
 
393
+ Composio uses its hosted MCP endpoint with the pinned OpenClaw engine's native OAuth client; BYOKit does not maintain a Composio app/tool list or install a separate plugin. `beginComposioSignIn()` configures `https://connect.composio.dev/mcp` for Streamable HTTP OAuth and returns `{ url }`; open it in the browser, then call `completeComposioSignIn()` to wait for OpenClaw's callback and token exchange. OpenClaw stores the OAuth credentials in its engine-owned SQLite state; when the app supplies `authSeal`, the kit seals that state at rest with other engine data. `signOutComposio()` invokes native MCP logout. Composio usage is billed under the person's own Composio plan.
394
+
393
395
  ## API at a glance
394
396
 
395
397
  Entries:
@@ -401,7 +403,7 @@ Entries:
401
403
 
402
404
  | Export | What it does |
403
405
  | --- | --- |
404
- | `OpenClawKit` (`.`) | Prepares, starts, supervises and stops the engine. `call` / `callDynamic` pass through to the Gateway, `onEvent` listens. Helpers: `ensureMember`, `routes`, `providers`, `signedIn`, `signIn`, `signOut`, `removeKey`, `migrateRetainedLogin`, `confirmRetainedLogin`, `toolNames`, `run`, `steer`, `abort`, `approvals`, `onApproval`, `decide`, `allowOnce`, `disallowOnce`, `patchConfig`, `getConfigKey`, `setConfigKey`, `memoryLimited`, `doctorContext` |
406
+ | `OpenClawKit` (`.`) | Prepares, starts, supervises and stops the engine. `call` / `callDynamic` pass through to the Gateway, `onEvent` listens. Helpers: `ensureMember`, `ensureHelper`, `routes`, `providers`, `signedIn`, `signIn`, `signOut`, `removeKey`, `migrateRetainedLogin`, `confirmRetainedLogin`, `toolNames`, `run`, `steer`, `abort`, `approvals`, `onApproval`, `decide`, `allowOnce`, `disallowOnce`, `patchConfig`, `getConfigKey`, `setConfigKey`, `memoryLimited`, `doctorContext` |
405
407
  | `ENGINE_VERSION`, `PROTOCOL_VERSION`, `OPERATOR_SCOPES` (`.`) | The pinned engine version, its protocol and the operator scopes the kit connects with |
406
408
  | `KitOptions`, `RunSpec`, `RunEvent`, `RunEnd`, `RunUsage`, `PlanWindow`, `Route`, `Approval`, `Decision`, `ToolSpec`, `ToolHost`, `KitState`, ... (`.`) | Public types (docs/runtime-kits.md §5.2) |
407
409
  | `GatewayMethods`, `GatewayMethod`, `GatewayParams`, `GatewayResult`, `GatewayEventName`, `GatewayEventPayload` (`.`) | The generated pass-through tables, so an app can write its own generic wrapper over `call` / `onEvent` once |
@@ -440,11 +442,42 @@ durably revokes its recorded subtree, closes parked gates/active calls, then ask
440
442
  revokes its subtree. An engine resume with a different run ID, or a dispatch with no retained receipt, is not
441
443
  silently granted authority. No prefix grants or `allowOnce` bypass are used.
442
444
 
443
- **Per-helper account binding is not included:** targets retain their own existing engine credentials, persona,
444
- workspace and history. This option never copies credentials or gives helpers another person's account. Legacy
445
+ **Targets keep their own sign-in:** a target retains its own engine credentials, persona, workspace and history; a
446
+ member's own helpers run on the member's account (below). This option never copies credentials or gives helpers
447
+ another person's account. Legacy
445
448
  in-flight jobs may retain their exact old session keys and approval owners; all new helper work uses the new
446
449
  mapping. Never copy agent SQLite or replay old sent actions. See the [binding contract](../../docs/runtime-kits.md#native-delegated-tool-admission-opt-in).
447
450
 
451
+ ## Helpers on a member's account
452
+
453
+ A member can keep several helpers, each with its own persona and its own conversations, all on one account the
454
+ member already signed in. Nothing is copied and nobody signs in again: a helper's runs go to the engine agent that
455
+ already holds that account, under the helper's own session keys, with the helper's persona as the system text.
456
+
457
+ ```ts
458
+ import { OpenClawKit, HelperError } from '@byokit/openclaw';
459
+
460
+ declare const kit: OpenClawKit;
461
+ const research = await kit.ensureHelper('umer', 'research', { account: 'openai', persona: 'You are Umer\'s research helper.' });
462
+ // research.sessionPrefix === 'agent:umer:h-research:'
463
+ const end = await kit.run({ member: 'umer', helper: 'research', sessionKey: `${research.sessionPrefix}main`,
464
+ message: 'Summarise the notes' });
465
+ try {
466
+ await kit.run({ member: 'umer', helper: 'ghost', sessionKey: 'agent:umer:h-ghost:main', message: 'hi' });
467
+ } catch (e) {
468
+ if (e instanceof HelperError) console.log(e.code, end.ok); // 'unknown_helper', before any request
469
+ }
470
+ ```
471
+
472
+ `account` is one of `kit.accounts(member)`, the API-key lane (`'<provider>.key'`) included; a key-lane helper's run
473
+ names that account, as any paid run does. A helper's run is bound to its account (pick, rests and locks apply), and
474
+ `RunRef.helper` names it on `ToolHost.gate` and `call`; its approvals are the member's. The persona is the run's whole
475
+ system text: a helper run refuses `system`. Refusals are a typed `HelperError` before any engine request: `invalid`
476
+ (ids, persona, `system`), `unknown_account`, `bound` (another account), `unknown_helper` (not ensured, or its account
477
+ was removed: never moved to another sign-in) and `session` (a key outside the helper, a helper key without `helper`,
478
+ or `move` of one). Helpers share their account's engine agent (its id and workspace files); a helper gains no tool,
479
+ builtin or key prefix the member's run would not. See [Helpers](../../docs/runtime-kits.md#583-helpers).
480
+
448
481
  ## Owned browser (unqualified handoff)
449
482
 
450
483
  `browser: { executablePath, members }` opts into isolated member Chromium profiles. It requires a closed
package/dist/engine.d.ts CHANGED
@@ -62,6 +62,14 @@ export declare class Engine {
62
62
  output: string;
63
63
  }>;
64
64
  private get path();
65
+ private runOwned;
66
+ private composioMcpLogin?;
67
+ private composioMcpContext;
68
+ beginComposioMcpLogin(): Promise<{
69
+ url: string;
70
+ }>;
71
+ completeComposioMcpLogin(): Promise<void>;
72
+ signOutComposio(): Promise<void>;
65
73
  private get claudeFolder();
66
74
  doctorContext(): {
67
75
  entry: string;
package/dist/engine.js CHANGED
@@ -341,6 +341,115 @@ export class Engine {
341
341
  });
342
342
  }
343
343
  get path() { return ['/usr/bin', '/bin', ...this.o.enginePath ?? []].join(':'); }
344
+ async runOwned(binary, args, env, timeoutMs, input) {
345
+ return new Promise((resolve, reject) => {
346
+ const child = spawn(binary, args, { cwd: this.root, env, stdio: ['pipe', 'pipe', 'pipe'] });
347
+ let output = '', timedOut = false;
348
+ this.started.add(child);
349
+ const keep = (chunk) => { output = (output + chunk.toString()).slice(-65_536); };
350
+ child.stdout.on('data', keep);
351
+ child.stderr.on('data', keep);
352
+ const timer = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, timeoutMs);
353
+ child.once('error', error => { clearTimeout(timer); this.started.forget(child); reject(error); });
354
+ child.once('close', code => { clearTimeout(timer); this.started.forget(child); resolve({ code: timedOut ? null : code, output }); });
355
+ child.stdin.end(input);
356
+ });
357
+ }
358
+ composioMcpLogin;
359
+ composioMcpContext() {
360
+ const { entry, env: inherited } = this.doctorContext();
361
+ const home = join(this.root, 'home');
362
+ return { entry, env: { ...inherited, HOME: home, XDG_CONFIG_HOME: join(home, '.config'),
363
+ XDG_DATA_HOME: join(home, '.local/share'), XDG_STATE_HOME: join(home, '.local/state') } };
364
+ }
365
+ beginComposioMcpLogin() {
366
+ if (this.composioMcpLogin)
367
+ throw new Error('Composio MCP sign-in is already pending.');
368
+ const { entry, env } = this.composioMcpContext();
369
+ const child = spawn(process.execPath, [entry, 'mcp', 'login', 'composio'], {
370
+ cwd: join(this.root, 'home'), env, stdio: ['ignore', 'pipe', 'pipe'],
371
+ });
372
+ this.started.add(child);
373
+ let output = '', urlFound = false;
374
+ let resolveUrl, rejectUrl;
375
+ let resolveDone;
376
+ const url = new Promise((resolve, reject) => { resolveUrl = resolve; rejectUrl = reject; });
377
+ const done = new Promise(resolve => { resolveDone = resolve; });
378
+ const timer = setTimeout(() => { child.kill('SIGKILL'); rejectUrl(new Error('OpenClaw MCP login did not return an authorization URL.')); }, 30_000);
379
+ const capture = (chunk) => {
380
+ output = (output + chunk.toString()).slice(-65_536);
381
+ const raw = output.match(/https:\/\/[^\s"'<>]+/)?.[0];
382
+ if (!raw || urlFound)
383
+ return;
384
+ try {
385
+ const parsed = new URL(raw);
386
+ if (parsed.protocol !== 'https:' || !['connect.composio.dev', 'login.composio.dev'].includes(parsed.hostname)
387
+ || parsed.username || parsed.password)
388
+ throw new Error('OpenClaw returned an unsafe Composio OAuth URL.');
389
+ urlFound = true;
390
+ clearTimeout(timer);
391
+ resolveUrl(parsed.href);
392
+ }
393
+ catch (error) {
394
+ clearTimeout(timer);
395
+ rejectUrl(error);
396
+ child.kill('SIGKILL');
397
+ }
398
+ };
399
+ child.stdout.on('data', capture);
400
+ child.stderr.on('data', capture);
401
+ child.once('error', error => {
402
+ clearTimeout(timer);
403
+ rejectUrl(error);
404
+ this.started.forget(child);
405
+ if (this.composioMcpLogin?.done === done && !urlFound)
406
+ this.composioMcpLogin = undefined;
407
+ resolveDone({ code: null, output });
408
+ });
409
+ child.once('close', code => {
410
+ clearTimeout(timer);
411
+ this.started.forget(child);
412
+ if (!urlFound) {
413
+ rejectUrl(new Error(`OpenClaw MCP login exited before authorization URL (exit ${code}).`));
414
+ if (this.composioMcpLogin?.done === done)
415
+ this.composioMcpLogin = undefined;
416
+ }
417
+ resolveDone({ code, output });
418
+ });
419
+ this.composioMcpLogin = { url, done, child };
420
+ return url.then(value => ({ url: value }));
421
+ }
422
+ async completeComposioMcpLogin() {
423
+ const pending = this.composioMcpLogin;
424
+ if (!pending)
425
+ throw new Error('No Composio MCP sign-in is pending.');
426
+ try {
427
+ await pending.url;
428
+ let timer;
429
+ const timedOut = new Promise(resolve => { timer = setTimeout(() => resolve(null), 10 * 60_000); timer.unref(); });
430
+ const result = await Promise.race([pending.done, timedOut]);
431
+ clearTimeout(timer);
432
+ if (!result) {
433
+ pending.child.kill('SIGKILL');
434
+ await pending.done;
435
+ throw new Error('Composio MCP sign-in timed out.');
436
+ }
437
+ if (result.code !== 0)
438
+ throw new Error(`Composio MCP sign-in did not complete (OpenClaw exit ${result.code}).`);
439
+ }
440
+ finally {
441
+ if (this.composioMcpLogin?.done === pending.done)
442
+ this.composioMcpLogin = undefined;
443
+ }
444
+ }
445
+ async signOutComposio() {
446
+ if (this.composioMcpLogin)
447
+ throw new Error('Cannot sign out while Composio MCP sign-in is pending.');
448
+ const { entry, env } = this.composioMcpContext();
449
+ const result = await this.runOwned(process.execPath, [entry, 'mcp', 'logout', 'composio'], env, 30_000);
450
+ if (result.code !== 0)
451
+ throw new Error(`Composio MCP sign-out failed (OpenClaw exit ${result.code}).`);
452
+ }
344
453
  // The engine's own Claude folder: the fallback login writes it and, only with claudeFallback, the gateway reads it.
345
454
  get claudeFolder() { return join(this.root, 'home', '.claude'); }
346
455
  doctorContext() {
@@ -350,7 +459,8 @@ export class Engine {
350
459
  OPENCLAW_STATE_DIR: join(this.root, 'state'), OPENCLAW_CONFIG_PATH: join(this.root, 'openclaw.json'),
351
460
  // Upstream eagerly imports/refreshes Codex OAuth. Only the CLI may see the person's login.
352
461
  CODEX_HOME: join(home, '.codex'),
353
- XDG_CACHE_HOME: join(home, '.cache'), ...this.claudeFallback ? { CLAUDE_CONFIG_DIR: this.claudeFolder } : {},
462
+ XDG_CACHE_HOME: join(home, '.cache'),
463
+ ...this.claudeFallback ? { CLAUDE_CONFIG_DIR: this.claudeFolder } : {},
354
464
  TMPDIR: join(this.root, 'tmp'), npm_config_cache: join(this.root, 'npm-cache'), npm_config_userconfig: join(this.root, 'npmrc'),
355
465
  OPENCLAW_NO_RESPAWN: '1', OPENCLAW_SKIP_CHANNELS: '1', OPENCLAW_DISABLE_BONJOUR: '1',
356
466
  OPENCLAW_EXEC_SHELL_SNAPSHOT: '0', OPENCLAW_LOAD_SHELL_ENV: '0', OPENCLAW_GATEWAY_TOKEN: this.token,
@@ -679,6 +789,7 @@ export class Engine {
679
789
  await this.starting?.catch(() => { });
680
790
  const child = this.child;
681
791
  await this.started.terminate(delay);
792
+ this.composioMcpLogin = undefined;
682
793
  if (child && child.exitCode === null && child.signalCode === null)
683
794
  throw new Error('engine did not stop; credential store still in use');
684
795
  this.child = undefined;
@@ -0,0 +1,25 @@
1
+ import { type Binding } from './accounts.ts';
2
+ import type { Account, AccountId, Helper, Member } from './types.ts';
3
+ export declare const HELPER_ID: RegExp;
4
+ /** A session key tail of some helper: only a run naming that helper speaks there. */
5
+ export declare const HELPER_TAIL: RegExp;
6
+ export type HelperErrorCode = 'invalid' | 'unknown_account' | 'bound' | 'unknown_helper' | 'session';
7
+ /** A refused helper request, before any run: `code` says which rule refused it. */
8
+ export declare class HelperError extends Error {
9
+ readonly code: HelperErrorCode;
10
+ constructor(code: HelperErrorCode, message: string);
11
+ }
12
+ export declare function createHelpers(ctx: {
13
+ root: string;
14
+ list(member: Member): Promise<Account[]>;
15
+ }): {
16
+ ensure(member: Member, helper: string, o: {
17
+ account: AccountId;
18
+ persona: string;
19
+ }): Promise<Helper>;
20
+ resolve(member: Member, helper: string): {
21
+ agentId: string;
22
+ binding: Binding;
23
+ persona: string;
24
+ };
25
+ };
@@ -0,0 +1,70 @@
1
+ // Helpers (5.8.3): a member's personas on one account the member already signed in. A helper is its own session
2
+ // namespace `agent:<member>:h-<helper>:` run on the agent holding that account, with its own persona as the run's system
3
+ // text: the pin shares no sign-in between agents (a shared agentDir is refused, authInheritance is global), so no agent
4
+ // is made and nothing is copied. Its record is a kit-owned file; the engine is never asked.
5
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { loadIndex } from './accounts.js';
8
+ import { KEY_PREFIX, MEMBER_ID } from './members.js';
9
+ export const HELPER_ID = /^[a-z][a-z0-9-]{0,15}$/;
10
+ /** A session key tail of some helper: only a run naming that helper speaks there. */
11
+ export const HELPER_TAIL = /^h-[a-z][a-z0-9-]{0,15}:/;
12
+ const PERSONA_MAX = 8192;
13
+ /** A refused helper request, before any run: `code` says which rule refused it. */
14
+ export class HelperError extends Error {
15
+ code;
16
+ constructor(code, message) {
17
+ super(message);
18
+ this.name = 'HelperError';
19
+ this.code = code;
20
+ }
21
+ }
22
+ export function createHelpers(ctx) {
23
+ const valid = (member, helper) => typeof member === 'string' && MEMBER_ID.test(member)
24
+ && !member.startsWith(KEY_PREFIX) && typeof helper === 'string' && HELPER_ID.test(helper);
25
+ const path = (member, helper) => join(ctx.root, 'helpers', member, `${helper}.json`);
26
+ const read = (member, helper) => {
27
+ try {
28
+ const saved = JSON.parse(readFileSync(path(member, helper), 'utf8'));
29
+ return saved?.v === 1 && typeof saved.account === 'string' && typeof saved.agent === 'string'
30
+ && typeof saved.persona === 'string' ? saved : undefined;
31
+ }
32
+ catch {
33
+ return undefined;
34
+ }
35
+ };
36
+ const view = (member, helper, saved) => ({ member, helper, account: saved.account, agentId: saved.agent, sessionPrefix: `agent:${member}:h-${helper}:` });
37
+ return {
38
+ async ensure(member, helper, o) {
39
+ const persona = o?.persona;
40
+ // The persona is the helper's whole system text: bounded, plain text (newlines and tabs only).
41
+ if (!valid(member, helper) || typeof persona !== 'string' || !persona.trim() || persona.length > PERSONA_MAX
42
+ || /[\u0000-\u0008\u000b-\u001f\u007f]/.test(persona))
43
+ throw new HelperError('invalid', `invalid helper: ${String(member)}/${String(helper)}`);
44
+ if (!(await ctx.list(member)).some((row) => row.id === o.account))
45
+ throw new HelperError('unknown_account', `unknown account: ${String(o.account)}`);
46
+ const agent = loadIndex(ctx.root, member).file.accounts[o.account]?.agent;
47
+ if (!agent)
48
+ throw new HelperError('unknown_account', `unknown account: ${o.account}`);
49
+ const before = read(member, helper);
50
+ if (before && (before.account !== o.account || before.agent !== agent))
51
+ throw new HelperError('bound', `helper ${helper} uses another account`);
52
+ const saved = { v: 1, account: o.account, agent, persona };
53
+ const file = path(member, helper);
54
+ mkdirSync(join(file, '..'), { recursive: true, mode: 0o700 });
55
+ writeFileSync(`${file}.tmp`, JSON.stringify(saved), { mode: 0o600 });
56
+ renameSync(`${file}.tmp`, file);
57
+ return view(member, helper, saved);
58
+ },
59
+ // A run's helper, fail closed: ensured by this kit, on an account the member's index still names on the same agent.
60
+ // A removed account leaves the helper unusable; it never moves to another sign-in.
61
+ resolve(member, helper) {
62
+ const saved = valid(member, helper) ? read(member, helper) : undefined;
63
+ const entry = saved && loadIndex(ctx.root, member).file.accounts[saved.account];
64
+ if (!saved || entry?.agent !== saved.agent)
65
+ throw new HelperError('unknown_helper', `unknown helper: ${String(helper)}`);
66
+ return { agentId: saved.agent, persona: saved.persona,
67
+ binding: saved.agent === member ? { agentId: member } : { agentId: saved.agent, id: saved.account } };
68
+ },
69
+ };
70
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { ENGINE_VERSION, OPERATOR_SCOPES, PROTOCOL_VERSION } from './constants.ts';
2
- export type { Account, AccountId, AccountPick, AccountRef, Approval, CallOptions, ChannelChat, ChannelMessage, Considered, Decision, Defaults, ModelInfo, MoveResult, PickWhy, Room, RoomSpan, RunSelection, SignInState, GatewayEventName, GatewayEventPayload, GatewayMethod, GatewayParams, GatewayResult, GatewayTransport, GateResult, Hello, KitState, KitEventName, KitEventPayload, BrowserPing, LearningCapture, LearningMode, Member, PlanWindow, Route, RunEnd, RunEvent, RunFrame, RunRef, DelegatedRun, DelegatedEvent, RunSpec, OutputSchema, SchemaOutput, RunUsage, ThinkingAsked, ThinkingReport, ThinkingUsed, SignInOptions, SignInView, TelegramChannel, ToolHost, ToolSpec, } from './types.ts';
2
+ export type { Account, AccountId, AccountPick, AccountRef, Approval, CallOptions, ChannelChat, ChannelMessage, Considered, Decision, Defaults, ModelInfo, MoveResult, PickWhy, Room, RoomSpan, RunSelection, SignInState, GatewayEventName, GatewayEventPayload, GatewayMethod, GatewayParams, GatewayResult, GatewayTransport, GateResult, Hello, KitState, KitEventName, KitEventPayload, BrowserPing, LearningCapture, LearningMode, Member, PlanWindow, Route, RunEnd, RunEvent, RunFrame, RunRef, Helper, DelegatedRun, DelegatedEvent, RunSpec, OutputSchema, SchemaOutput, RunUsage, ThinkingAsked, ThinkingReport, ThinkingUsed, SignInOptions, SignInView, TelegramChannel, ToolHost, ToolSpec, } from './types.ts';
3
3
  export type { GatewayMethods } from './generated/methods.ts';
4
4
  export type { JsonValue } from './output.ts';
5
5
  export type { RouteFacts, RouteView } from './routes.ts';
@@ -7,6 +7,7 @@ export { OpenClawKit, type KitOptions, type RetainedLogin } from './kit.ts';
7
7
  export type { AddKeyHandle, AddKeyResult, KeyFailureReason } from './keys.ts';
8
8
  export { stateWords, toAccountView, words, type AccountView, type WordKey } from './words.ts';
9
9
  export { chooseAccount, consider, resolveSelection, type RoomOf } from './pick.ts';
10
+ export { HelperError, type HelperErrorCode } from './helpers.ts';
10
11
  export { AuthStoreUnreadableError, AuthStoreSealSizeError } from './auth-store.ts';
11
12
  export { EngineAlreadyRunningError } from './engine-status.ts';
12
13
  export { PluginInstallError, type PluginInstallFailure } from './plugins.ts';
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ export { ENGINE_VERSION, OPERATOR_SCOPES, PROTOCOL_VERSION } from './constants.j
4
4
  export { OpenClawKit } from './kit.js';
5
5
  export { stateWords, toAccountView, words } from './words.js';
6
6
  export { chooseAccount, consider, resolveSelection } from './pick.js';
7
+ export { HelperError } from './helpers.js';
7
8
  export { AuthStoreUnreadableError, AuthStoreSealSizeError } from './auth-store.js';
8
9
  export { EngineAlreadyRunningError } from './engine-status.js';
9
10
  export { PluginInstallError } from './plugins.js';
package/dist/kit.d.ts CHANGED
@@ -2,7 +2,7 @@ import type { SealingAdapter } from '@byokit/secrets';
2
2
  import { type AddKeyHandle } from './keys.ts';
3
3
  import { type RouteView } from './routes.ts';
4
4
  import type { BrowserHost, BrowserOptions } from './browser.ts';
5
- import type { Account, AccountId, AccountPick, Approval, CallOptions, Decision, Defaults, GatewayMethod, GatewayParams, GatewayResult, GatewayTransport, Hello, KitState, KitEventName, KitEventPayload, LearningCapture, LearningMode, Member, ModelInfo, MoveResult, Room, RunEnd, RunEvent, RunRef, RunSelection, RunSpec, OutputSchema, SchemaOutput, SignInOptions, SignInView, ChannelChat, TelegramChannel, ToolHost, ToolSpec } from './types.ts';
5
+ import type { Account, AccountId, AccountPick, Approval, CallOptions, Decision, Defaults, GatewayMethod, GatewayParams, GatewayResult, GatewayTransport, Hello, Helper, KitState, KitEventName, KitEventPayload, LearningCapture, LearningMode, Member, ModelInfo, MoveResult, Room, RunEnd, RunEvent, RunRef, RunSelection, RunSpec, OutputSchema, SchemaOutput, SignInOptions, SignInView, ChannelChat, TelegramChannel, ToolHost, ToolSpec } from './types.ts';
6
6
  export type KitOptions = {
7
7
  stateDir: string;
8
8
  authSeal?: SealingAdapter;
@@ -68,6 +68,7 @@ export declare class OpenClawKit {
68
68
  attach(member: Member, runId: string, fromSeq: number, signal: AbortSignal): AsyncIterable<import("./types.ts").RunFrame>;
69
69
  private installs;
70
70
  private accountsCtl?;
71
+ private helpersCtl?;
71
72
  private readonly bridge;
72
73
  private readonly delegations;
73
74
  private readonly keys;
@@ -129,8 +130,21 @@ export declare class OpenClawKit {
129
130
  agentId: string;
130
131
  workspace: string;
131
132
  }>;
133
+ /** One of the member's helpers (5.8.3): its own persona and sessions on the member's `account`, no new sign-in. */
134
+ ensureHelper(member: Member, helper: string, o: {
135
+ account: AccountId;
136
+ persona: string;
137
+ }): Promise<Helper>;
132
138
  private signInCtx;
133
139
  routes(): RouteView[];
140
+ /** Start Composio's hosted MCP OAuth flow through the pinned engine; return the URL for the app to open. */
141
+ beginComposioSignIn(): Promise<{
142
+ url: string;
143
+ }>;
144
+ /** Wait for OpenClaw's native MCP OAuth callback and token exchange to finish. */
145
+ completeComposioSignIn(): Promise<void>;
146
+ /** Clear the engine-owned native MCP OAuth credential for Composio. */
147
+ signOutComposio(): Promise<void>;
134
148
  /** B7 (5.7): install one external provider plugin a `needs_plugin` route names into this kit's own engine, at its
135
149
  * pin; resolves once the restarted gateway lists it enabled at that version. Discovery never installs. Serialized:
136
150
  * concurrent installs would race the allow-list update and double-spawn the installer into one engine tree. */
package/dist/kit.js CHANGED
@@ -14,6 +14,7 @@ import { Engine, canonicalStateDir, refusedOnly } from './engine.js';
14
14
  import { AuthStoreSealSizeError } from './auth-store.js';
15
15
  import { gatewayTransport } from './transport.js';
16
16
  import { createMembers } from './members.js';
17
+ import { createHelpers, HELPER_TAIL, HelperError } from './helpers.js';
17
18
  import { createAccounts, memberOfAccount } from './accounts.js';
18
19
  import { createLocks } from './locks.js';
19
20
  import { keyAgentId, KEY_PREFIX, MEMBER_ID } from './members.js';
@@ -448,6 +449,7 @@ export class OpenClawKit {
448
449
  }
449
450
  installs = Promise.resolve();
450
451
  accountsCtl;
452
+ helpersCtl;
451
453
  // One bridge and one approval surface per kit: listeners and run registrations survive reconnects (B3).
452
454
  // Only the socket itself is per-connection. Approvals always reaches the live transport through this.request().
453
455
  bridge;
@@ -833,6 +835,7 @@ export class OpenClawKit {
833
835
  ensure: (member) => this.ensureMember(member), locks: this.locks,
834
836
  signIn: (agentId, o, on, fresh) => startSignIn({ ...this.signInCtx(), ensure: async () => ({ agentId }), fresh }, agentId, o, on),
835
837
  routes: this.routeMap, log: this.o.log, reseal: () => this.engine.authStore.reseal() });
838
+ this.helpersCtl = createHelpers({ root: this.engine.root, list: (member) => this.accounts(member) });
836
839
  // The fail-closed tool bridge feeds the kit's single Approval surface (5.9, O5).
837
840
  await Promise.race([this.bridge.start(), exit]);
838
841
  this.off.push(transport.onEvent((e) => this.approvalsCtl.handleEvent(e)));
@@ -1068,6 +1071,12 @@ export class OpenClawKit {
1068
1071
  await attaching;
1069
1072
  return result;
1070
1073
  }
1074
+ /** One of the member's helpers (5.8.3): its own persona and sessions on the member's `account`, no new sign-in. */
1075
+ ensureHelper(member, helper, o) {
1076
+ if (!this.helpersCtl)
1077
+ return Promise.reject(new Error('gateway not ready'));
1078
+ return this.helpersCtl.ensure(member, helper, o);
1079
+ }
1071
1080
  // sign-in (5.7): every body here is the module's, with this kit's transport, members and ports.
1072
1081
  signInCtx() {
1073
1082
  return { request: (method, params, o) => this.request()(method, params, o),
@@ -1089,6 +1098,17 @@ export class OpenClawKit {
1089
1098
  routes() {
1090
1099
  return routeTable({ plugins: installedPlugins(this.engine.root) });
1091
1100
  }
1101
+ /** Start Composio's hosted MCP OAuth flow through the pinned engine; return the URL for the app to open. */
1102
+ async beginComposioSignIn() {
1103
+ await this.patchConfig({ mcp: { servers: { composio: {
1104
+ url: 'https://connect.composio.dev/mcp', transport: 'streamable-http', auth: 'oauth', enabled: true,
1105
+ } } } });
1106
+ return this.engine.beginComposioMcpLogin();
1107
+ }
1108
+ /** Wait for OpenClaw's native MCP OAuth callback and token exchange to finish. */
1109
+ completeComposioSignIn() { return this.engine.completeComposioMcpLogin(); }
1110
+ /** Clear the engine-owned native MCP OAuth credential for Composio. */
1111
+ signOutComposio() { return this.engine.signOutComposio(); }
1092
1112
  /** B7 (5.7): install one external provider plugin a `needs_plugin` route names into this kit's own engine, at its
1093
1113
  * pin; resolves once the restarted gateway lists it enabled at that version. Discovery never installs. Serialized:
1094
1114
  * concurrent installs would race the allow-list update and double-spawn the installer into one engine tree. */
@@ -1215,6 +1235,8 @@ export class OpenClawKit {
1215
1235
  move(ref, to) {
1216
1236
  if (!this.accountsCtl)
1217
1237
  return Promise.reject(new Error('gateway not ready'));
1238
+ if (HELPER_TAIL.test(String(ref.sessionKey).replace(/^agent:[^:]+:/, '')))
1239
+ return Promise.reject(new HelperError('session', 'refused: a helper\'s session stays on its account'));
1218
1240
  return this.accountsCtl.move(ref.member, ref.sessionKey, to);
1219
1241
  }
1220
1242
  /** Sign in one more account: on the member agent while it holds none of the provider, else on a new sealed agent. */
@@ -1336,6 +1358,7 @@ export class OpenClawKit {
1336
1358
  bridge: { register: (run, tools, runId) => this.bridge.register(run, tools && this.o.browser ? [...tools, 'request_sign_in'] : tools, runId) },
1337
1359
  tools: new Set(this.toolNames()), delegations: this.delegations,
1338
1360
  ...(this.accountsCtl ? { accounts: this.accountsCtl } : {}),
1361
+ ...(this.helpersCtl ? { helpers: this.helpersCtl } : {}),
1339
1362
  locks: this.locks,
1340
1363
  routes: this.routeMap,
1341
1364
  });
package/dist/runs.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Bridge } from './bridge.ts';
2
+ import { type createHelpers } from './helpers.ts';
2
3
  import type { Delegations } from './delegations.ts';
3
4
  import type { createAccounts } from './accounts.ts';
4
5
  import type { createLocks } from './locks.ts';
@@ -40,6 +41,7 @@ export declare function createRuns(ctx: {
40
41
  }>;
41
42
  bridge: Pick<Bridge, 'register'>;
42
43
  delegations?: Delegations;
44
+ helpers?: Pick<ReturnType<typeof createHelpers>, 'resolve'>;
43
45
  tools: ReadonlySet<string>;
44
46
  accounts?: Pick<ReturnType<typeof createAccounts>, 'bind' | 'keyRefusal' | 'pick' | 'rest'>;
45
47
  locks?: ReturnType<typeof createLocks>;
package/dist/runs.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // Runs: one Gateway run per spec, streamed through typed events, ending in a classified RunEnd (5.8, O8).
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { setTimeout as delay } from 'node:timers/promises';
4
+ import { HELPER_TAIL, HelperError } from './helpers.js';
4
5
  import { authStatus } from './auth-status.js';
5
6
  import { outputSchema } from './output.js';
6
7
  import { words } from './words.js';
@@ -163,6 +164,14 @@ export function createRuns(ctx) {
163
164
  // of one of the member's account agents is the member's too; binding below checks the index names it.
164
165
  if (!spec.sessionKey.startsWith(`agent:${spec.member}:`) && !(ctx.accounts && !keyAgent && spec.sessionKey.startsWith(`agent:${spec.member}--`)))
165
166
  throw new Error(`refused: "${spec.sessionKey}" is not a session of member "${spec.member}"`);
167
+ const tail = spec.sessionKey.slice(spec.sessionKey.indexOf(':', 'agent:'.length) + 1);
168
+ // A helper (5.8.3) speaks only under `agent:<member>:h-<helper>:`, with its persona as the whole system text, and
169
+ // only a run naming that helper speaks there. Refused before any request.
170
+ if (spec.helper !== undefined && (!ctx.helpers || keyAgent || spec.system !== undefined))
171
+ throw new HelperError('invalid', `refused: helper ${String(spec.helper)} runs on its own account with its own persona`);
172
+ const helper = spec.helper === undefined ? undefined : ctx.helpers.resolve(spec.member, spec.helper);
173
+ if (helper ? !spec.sessionKey.startsWith(`agent:${spec.member}:h-${spec.helper}:`) : HELPER_TAIL.test(tail))
174
+ throw new HelperError('session', `refused: "${spec.sessionKey}" is not a session of ${helper ? `helper "${spec.helper}"` : 'this run'}`);
166
175
  if (spec.delegates) {
167
176
  if (spec.register === false || !ctx.delegations)
168
177
  throw new Error('delegation requires a registered run');
@@ -173,16 +182,16 @@ export function createRuns(ctx) {
173
182
  throw new Error('refused: idempotencyKey must be a non-empty string');
174
183
  const idempotencyKey = spec.idempotencyKey ?? randomUUID();
175
184
  const output = preparedOutput ?? (spec.schema === undefined ? undefined : outputSchema(spec.schema));
176
- const system = [spec.system, output?.prompt].filter(Boolean).join('\n\n');
185
+ const system = [helper?.persona ?? spec.system, output?.prompt].filter(Boolean).join('\n\n');
177
186
  const picked = spec.model === undefined ? undefined : account(spec.model);
178
187
  for (const need of spec.needs ?? [])
179
188
  account(need);
180
189
  for (const tool of spec.tools ?? [])
181
190
  if (!ctx.tools.has(tool))
182
191
  throw new Error(`refused: "${tool}" is not one of this kit's tools`);
183
- const tail = spec.sessionKey.slice(spec.sessionKey.indexOf(':', 'agent:'.length) + 1);
184
192
  // Runs step 1 (5.15): every run first finds the account its session is bound to, the API-key lane included.
185
- const binding = ctx.accounts ? await ctx.accounts.bind(spec.member, spec.sessionKey) : undefined;
193
+ // A helper is bound to the agent whose sign-in it reads, as a conversation held there is.
194
+ const binding = helper?.binding ?? (ctx.accounts ? await ctx.accounts.bind(spec.member, spec.sessionKey) : undefined);
186
195
  // Step 2 on the API-key lane: its own lane binding continues; any other binding is refused.
187
196
  if (keyAgent && ctx.accounts && binding) {
188
197
  const message = await ctx.accounts.keyRefusal(spec.member, binding);
@@ -191,7 +200,7 @@ export function createRuns(ctx) {
191
200
  }
192
201
  // A run that waited for its agent's lock across a move (5.15) binds again under it: a changed binding means its
193
202
  // session went to another account, so it ends before any event and the caller's retry lands there.
194
- const moved = async () => !!binding && (await ctx.accounts.bind(spec.member, spec.sessionKey))?.agentId !== binding.agentId;
203
+ const moved = async () => !helper && !!binding && (await ctx.accounts.bind(spec.member, spec.sessionKey))?.agentId !== binding.agentId;
195
204
  const movedEnd = { ok: false, kind: 'other', message: words('run.moved') };
196
205
  // Step 3: with `account` and `needs` both absent, on a key no account agent holds (or an API-key run, which binds only
197
206
  // to its lane), exactly the run on the agent it always was (a bound key under its shared lock).
@@ -325,8 +334,9 @@ export function createRuns(ctx) {
325
334
  }
326
335
  });
327
336
  try {
328
- releaseDelegation = spec.delegates ? ctx.delegations.register({ sessionKey, member: spec.member }, idempotencyKey, spec.tools, spec.delegates, spec.sessionKey) : undefined;
329
- release = spec.register !== false ? ctx.bridge.register({ sessionKey, member: spec.member }, spec.tools, idempotencyKey) : undefined;
337
+ const ref = { sessionKey, member: spec.member, ...(spec.helper !== undefined ? { helper: spec.helper } : {}) };
338
+ releaseDelegation = spec.delegates ? ctx.delegations.register(ref, idempotencyKey, spec.tools, spec.delegates, spec.sessionKey) : undefined;
339
+ release = spec.register !== false ? ctx.bridge.register(ref, spec.tools, idempotencyKey) : undefined;
330
340
  if (picked && !chosen) {
331
341
  // The picked account must be the one called: check it is signed in for this member before the run, since
332
342
  // the engine would otherwise fail the run only after admitting it. An explicit provider/model is strict on
package/dist/types.d.ts CHANGED
@@ -19,6 +19,7 @@ export interface ToolSpec {
19
19
  export interface RunRef {
20
20
  sessionKey: string;
21
21
  member: Member;
22
+ helper?: string;
22
23
  meta?: unknown;
23
24
  runId?: string;
24
25
  agentId?: string;
@@ -31,6 +32,15 @@ export interface RunRef {
31
32
  runId: string;
32
33
  };
33
34
  }
35
+ /** One of a member's helpers (5.8.3): its own persona and sessions (`sessionPrefix`) on `account`, run by `agentId`, the
36
+ * engine agent already holding that sign-in. */
37
+ export type Helper = {
38
+ member: Member;
39
+ helper: string;
40
+ account: AccountId;
41
+ agentId: string;
42
+ sessionPrefix: string;
43
+ };
34
44
  export type DelegatedRun = RunRef & Required<Pick<RunRef, 'runId' | 'agentId' | 'parent' | 'root'>>;
35
45
  export type DelegatedEvent = {
36
46
  type: 'admitted' | 'event' | 'ended';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/openclaw",
3
- "version": "0.18.3",
3
+ "version": "0.20.0",
4
4
  "description": "The OpenClaw runtime kit: the pinned engine's full operator surface with plain-words helpers for members, sign-in, runs and approvals.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",