-
Setting up your workspace...
+
{progressMessage}
This will only take a moment.
diff --git a/apps/web/src/hooks/use-squadhub-status.ts b/apps/web/src/hooks/use-squadhub-status.ts
index 242e772..bca07af 100644
--- a/apps/web/src/hooks/use-squadhub-status.ts
+++ b/apps/web/src/hooks/use-squadhub-status.ts
@@ -22,8 +22,8 @@ export const useSquadhubStatus = () => {
return false;
}
},
- refetchInterval: 30000, // Check every 30 seconds
- staleTime: 10000,
+ refetchInterval: 10000, // Check every 10 seconds
+ staleTime: 5000,
retry: false,
});
diff --git a/apps/web/src/lib/api/tenant-auth.ts b/apps/web/src/lib/api/tenant-auth.ts
index 7e0bd7b..724cfd0 100644
--- a/apps/web/src/lib/api/tenant-auth.ts
+++ b/apps/web/src/lib/api/tenant-auth.ts
@@ -2,13 +2,20 @@ import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { ConvexHttpClient } from "convex/browser";
import { api } from "@clawe/backend";
+import type { Tenant } from "@clawe/backend/types";
+
+export type AuthResult =
+ | { error: NextResponse; convex: null; tenant: null }
+ | { error: null; convex: ConvexHttpClient; tenant: Tenant };
/**
* Extract the auth token and resolve the tenant for the current user.
* Returns a Convex client (with auth set) and the tenant record,
* or a NextResponse error if auth/tenant resolution fails.
*/
-export async function getAuthenticatedTenant(request: NextRequest) {
+export async function getAuthenticatedTenant(
+ request: NextRequest,
+): Promise
{
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL;
if (!convexUrl) {
return {
@@ -16,6 +23,8 @@ export async function getAuthenticatedTenant(request: NextRequest) {
{ error: "NEXT_PUBLIC_CONVEX_URL not configured" },
{ status: 500 },
),
+ convex: null,
+ tenant: null,
};
}
@@ -30,6 +39,8 @@ export async function getAuthenticatedTenant(request: NextRequest) {
{ error: "Missing Authorization header" },
{ status: 401 },
),
+ convex: null,
+ tenant: null,
};
}
@@ -44,8 +55,10 @@ export async function getAuthenticatedTenant(request: NextRequest) {
{ error: "No tenant found for current user" },
{ status: 404 },
),
+ convex: null,
+ tenant: null,
};
}
- return { convex, tenant };
+ return { error: null, convex, tenant };
}
diff --git a/apps/web/src/lib/squadhub/actions.spec.ts b/apps/web/src/lib/squadhub/actions.spec.ts
index 8f1b6d6..2bc5b24 100644
--- a/apps/web/src/lib/squadhub/actions.spec.ts
+++ b/apps/web/src/lib/squadhub/actions.spec.ts
@@ -6,7 +6,7 @@ vi.mock("@clawe/shared/squadhub", () => ({
getConfig: vi.fn(),
saveTelegramBotToken: vi.fn(),
probeTelegramToken: vi.fn(),
- approveChannelPairingCode: vi.fn(),
+ approvePairingCode: vi.fn(),
}));
import {
diff --git a/apps/web/src/lib/squadhub/actions.ts b/apps/web/src/lib/squadhub/actions.ts
index ffe25e0..dc85a51 100644
--- a/apps/web/src/lib/squadhub/actions.ts
+++ b/apps/web/src/lib/squadhub/actions.ts
@@ -6,7 +6,8 @@ import {
saveTelegramBotToken as saveTelegramBotTokenClient,
removeTelegramBotToken as removeTelegramBotTokenClient,
probeTelegramToken,
- approveChannelPairingCode,
+ approvePairingCode as approvePairingCodeClient,
+ parseToolText,
} from "@clawe/shared/squadhub";
import { getConnection } from "./connection";
@@ -40,7 +41,36 @@ export async function approvePairingCode(
code: string,
channel: string = "telegram",
) {
- return approveChannelPairingCode(getConnection(), channel, code);
+ const result = await approvePairingCodeClient(getConnection(), channel, code);
+
+ if (!result.ok) {
+ return {
+ ok: false as const,
+ error: { type: "tool_error", message: result.error.message },
+ };
+ }
+
+ const data = parseToolText<{
+ ok: boolean;
+ id?: string;
+ approved?: boolean;
+ error?: string;
+ }>(result);
+
+ if (!data?.ok) {
+ return {
+ ok: false as const,
+ error: {
+ type: "not_found",
+ message: data?.error || "Invalid or expired pairing code",
+ },
+ };
+ }
+
+ return {
+ ok: true as const,
+ result: { id: data.id, approved: data.approved },
+ };
}
export async function removeTelegramBot() {
diff --git a/apps/web/src/lib/squadhub/connection.ts b/apps/web/src/lib/squadhub/connection.ts
index 4b7f642..9e47f40 100644
--- a/apps/web/src/lib/squadhub/connection.ts
+++ b/apps/web/src/lib/squadhub/connection.ts
@@ -1,8 +1,17 @@
import type { SquadhubConnection } from "@clawe/shared/squadhub";
+import type { Tenant } from "@clawe/backend/types";
-export function getConnection(): SquadhubConnection {
+/**
+ * Get the squadhub connection for a tenant.
+ * If a tenant with squadhubUrl/squadhubToken is provided, uses those.
+ * Otherwise falls back to env vars (self-hosted / dev).
+ */
+export function getConnection(tenant?: Tenant | null): SquadhubConnection {
return {
- squadhubUrl: process.env.SQUADHUB_URL || "http://localhost:18790",
- squadhubToken: process.env.SQUADHUB_TOKEN || "",
+ squadhubUrl:
+ tenant?.squadhubUrl ||
+ process.env.SQUADHUB_URL ||
+ "http://localhost:18790",
+ squadhubToken: tenant?.squadhubToken || process.env.SQUADHUB_TOKEN || "",
};
}
diff --git a/apps/web/src/test/mock-tenant-auth.ts b/apps/web/src/test/mock-tenant-auth.ts
new file mode 100644
index 0000000..b19a9b7
--- /dev/null
+++ b/apps/web/src/test/mock-tenant-auth.ts
@@ -0,0 +1,21 @@
+import { vi } from "vitest";
+
+/**
+ * Shared mock for `@/lib/api/tenant-auth` used across API route tests.
+ * Import this before the route under test to set up the mock.
+ *
+ * Usage:
+ * vi.mock("@/lib/api/tenant-auth", () => mockTenantAuth);
+ */
+export const mockTenantAuth = {
+ getAuthenticatedTenant: vi.fn(async () => ({
+ error: null,
+ convex: {},
+ tenant: {
+ _id: "test-tenant-id",
+ squadhubUrl: "http://localhost:18790",
+ squadhubToken: "test-token",
+ status: "active",
+ },
+ })),
+};
diff --git a/docker/squadhub/Dockerfile b/docker/squadhub/Dockerfile
index 3dbc35f..147df6b 100644
--- a/docker/squadhub/Dockerfile
+++ b/docker/squadhub/Dockerfile
@@ -11,7 +11,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \
&& rm -rf /var/lib/apt/lists/*
-RUN npm install -g openclaw@latest
+RUN npm install -g openclaw@2026.2.17
RUN mkdir -p /data/config /data/workspace
@@ -24,6 +24,11 @@ RUN chmod +x /opt/clawe/scripts/*.sh
COPY packages/cli/dist/clawe.js /usr/local/bin/clawe
RUN chmod +x /usr/local/bin/clawe
+# Copy Clawe OpenClaw extensions (plugin bundle + manifest)
+COPY packages/squadhub-extensions/dist/index.js /opt/clawe/extensions/clawe/dist/index.js
+COPY packages/squadhub-extensions/openclaw.plugin.json /opt/clawe/extensions/clawe/openclaw.plugin.json
+COPY packages/squadhub-extensions/package.json /opt/clawe/extensions/clawe/package.json
+
ENV OPENCLAW_STATE_DIR=/data/config
ENV OPENCLAW_PORT=18789
ENV OPENCLAW_SKIP_GMAIL_WATCHER=1
diff --git a/docker/squadhub/entrypoint.sh b/docker/squadhub/entrypoint.sh
index 0e01c04..50fef3f 100644
--- a/docker/squadhub/entrypoint.sh
+++ b/docker/squadhub/entrypoint.sh
@@ -62,8 +62,12 @@ node /opt/clawe/scripts/pair-device.js --watch &
echo "==> Starting OpenClaw gateway on port $PORT..."
+# OpenClaw does a "full process restart" on certain config changes (e.g.
+# adding a Telegram channel). It spawns a child process and the parent
+# exits. In Docker, PID 1 exiting kills the container. We rely on
+# Docker's restart policy (restart: unless-stopped) to handle this.
exec openclaw gateway run \
--port "$PORT" \
- --bind 0.0.0.0 \
+ --bind lan \
--token "$TOKEN" \
--allow-unconfigured
diff --git a/docker/squadhub/templates/config.template.json b/docker/squadhub/templates/config.template.json
index df02ef7..3c0bbd1 100644
--- a/docker/squadhub/templates/config.template.json
+++ b/docker/squadhub/templates/config.template.json
@@ -64,6 +64,11 @@
}
]
},
+ "plugins": {
+ "load": {
+ "paths": ["/opt/clawe/extensions/clawe"]
+ }
+ },
"tools": {
"agentToAgent": {
"enabled": true,
@@ -97,6 +102,9 @@
"mode": "token",
"token": "${OPENCLAW_TOKEN}"
},
+ "tools": {
+ "allow": ["gateway"]
+ },
"http": {
"endpoints": {
"chatCompletions": {
diff --git a/package.json b/package.json
index 412eba4..1463b1e 100644
--- a/package.json
+++ b/package.json
@@ -4,7 +4,7 @@
"scripts": {
"build": "dotenv -e .env -- turbo run build",
"dev": "dotenv -e .env -- turbo run dev",
- "dev:docker": "pnpm --filter @clawe/cli build && docker compose up --build squadhub",
+ "dev:docker": "pnpm --filter @clawe/cli build && pnpm --filter @clawe/squadhub-extensions build && docker compose up --build squadhub",
"build:docker": "pnpm --filter @clawe/cli build && docker compose build --no-cache squadhub",
"debug": "dotenv -e .env -- turbo run debug",
"dev:web": "dotenv -e .env -- turbo run dev --filter=web",
diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts
index 9b9ae44..86a4697 100644
--- a/packages/backend/convex/_generated/api.d.ts
+++ b/packages/backend/convex/_generated/api.d.ts
@@ -15,6 +15,7 @@ import type * as businessContext from "../businessContext.js";
import type * as channels from "../channels.js";
import type * as documents from "../documents.js";
import type * as lib_auth from "../lib/auth.js";
+import type * as lib_helpers from "../lib/helpers.js";
import type * as messages from "../messages.js";
import type * as notifications from "../notifications.js";
import type * as routines from "../routines.js";
@@ -37,6 +38,7 @@ declare const fullApi: ApiFromModules<{
channels: typeof channels;
documents: typeof documents;
"lib/auth": typeof lib_auth;
+ "lib/helpers": typeof lib_helpers;
messages: typeof messages;
notifications: typeof notifications;
routines: typeof routines;
diff --git a/packages/backend/convex/lib/auth.ts b/packages/backend/convex/lib/auth.ts
index a2532dc..c4076f6 100644
--- a/packages/backend/convex/lib/auth.ts
+++ b/packages/backend/convex/lib/auth.ts
@@ -1,5 +1,6 @@
-import type { Doc, Id } from "../_generated/dataModel";
+import type { Id } from "../_generated/dataModel";
import type { MutationCtx, QueryCtx } from "../_generated/server";
+import type { User, Account } from "../types";
type ReadCtx = { db: QueryCtx["db"]; auth: QueryCtx["auth"] };
type WriteCtx = { db: MutationCtx["db"] };
@@ -135,8 +136,8 @@ export async function resolveTenantId(
*/
export async function ensureAccountForUser(
ctx: ReadCtx & WriteCtx,
- user: Doc<"users">,
-): Promise> {
+ user: User,
+): Promise {
const membership = await ctx.db
.query("accountMembers")
.withIndex("by_user", (q) => q.eq("userId", user._id))
diff --git a/packages/backend/convex/lib/helpers.ts b/packages/backend/convex/lib/helpers.ts
index 33a29aa..0b4a4ac 100644
--- a/packages/backend/convex/lib/helpers.ts
+++ b/packages/backend/convex/lib/helpers.ts
@@ -1,11 +1,12 @@
import type { QueryCtx } from "../_generated/server";
-import type { Doc, Id } from "../_generated/dataModel";
+import type { Id } from "../_generated/dataModel";
+import type { Agent } from "../types";
export async function getAgentBySessionKey(
ctx: { db: QueryCtx["db"] },
tenantId: Id<"tenants">,
sessionKey: string,
-): Promise | null> {
+): Promise {
return await ctx.db
.query("agents")
.withIndex("by_tenant_sessionKey", (q) =>
diff --git a/packages/backend/convex/types.ts b/packages/backend/convex/types.ts
index bf5ef13..ab28d1a 100644
--- a/packages/backend/convex/types.ts
+++ b/packages/backend/convex/types.ts
@@ -8,6 +8,23 @@
import type { Doc, Id } from "./_generated/dataModel";
+// =============================================================================
+// User & Account Types
+// =============================================================================
+
+/** User document */
+export type User = Doc<"users">;
+
+/** Account document */
+export type Account = Doc<"accounts">;
+
+// =============================================================================
+// Tenant Types
+// =============================================================================
+
+/** Tenant document */
+export type Tenant = Doc<"tenants">;
+
// =============================================================================
// Agent Types
// =============================================================================
diff --git a/packages/shared/src/squadhub/client.ts b/packages/shared/src/squadhub/client.ts
index 0d5b156..9416f25 100644
--- a/packages/shared/src/squadhub/client.ts
+++ b/packages/shared/src/squadhub/client.ts
@@ -6,6 +6,7 @@ import type {
SessionsListResult,
GatewayHealthResult,
TelegramProbeResult,
+ PairingRequest,
} from "./types";
export type SquadhubConnection = {
@@ -57,6 +58,23 @@ async function invokeTool(
}
}
+/**
+ * Parse the text payload from a tool invoke result.
+ * Tool results wrap their data as JSON inside `content[0].text`.
+ */
+export function parseToolText>(
+ result: ToolResult,
+): T | null {
+ if (!result.ok) return null;
+ const text = result.result.content[0]?.text;
+ if (!text) return null;
+ try {
+ return JSON.parse(text) as T;
+ } catch {
+ return null;
+ }
+}
+
export async function checkHealth(
connection: SquadhubConnection,
): Promise> {
@@ -199,6 +217,31 @@ export async function sessionsSend(
});
}
+// Pairing (via clawe_pairing plugin tool)
+export async function listPairingRequests(
+ connection: SquadhubConnection,
+ channel: string,
+): Promise> {
+ return invokeTool(connection, "clawe_pairing", undefined, {
+ action: "list",
+ channel,
+ });
+}
+
+export async function approvePairingCode(
+ connection: SquadhubConnection,
+ channel: string,
+ code: string,
+): Promise<
+ ToolResult<{ ok: boolean; id?: string; approved?: boolean; error?: string }>
+> {
+ return invokeTool(connection, "clawe_pairing", undefined, {
+ action: "approve",
+ channel,
+ code,
+ });
+}
+
// Cron types (matching squadhub src/cron/types.ts)
export type CronSchedule =
| { kind: "at"; at: string }
diff --git a/packages/shared/src/squadhub/index.ts b/packages/shared/src/squadhub/index.ts
index 10d4321..6832bbc 100644
--- a/packages/shared/src/squadhub/index.ts
+++ b/packages/shared/src/squadhub/index.ts
@@ -11,6 +11,9 @@ export {
sessionsSend,
cronList,
cronAdd,
+ listPairingRequests,
+ approvePairingCode,
+ parseToolText,
} from "./client";
export type {
SquadhubConnection,
@@ -31,17 +34,10 @@ export { GatewayClient, createGatewayClient } from "./gateway-client";
export type { GatewayClientOptions } from "./gateway-client";
export { getSharedClient } from "./shared-client";
-// Pairing
-export {
- listChannelPairingRequests,
- approveChannelPairingCode,
-} from "./pairing";
-
// Types
export type {
AgentToolResult,
ToolResult,
- DirectResult,
ConfigGetResult,
ConfigPatchResult,
Session,
@@ -50,8 +46,6 @@ export type {
GatewayHealthResult,
TelegramProbeResult,
PairingRequest,
- PairingListResult,
- PairingApproveResult,
} from "./types";
// Gateway Types
diff --git a/packages/shared/src/squadhub/pairing.ts b/packages/shared/src/squadhub/pairing.ts
deleted file mode 100644
index 7591345..0000000
--- a/packages/shared/src/squadhub/pairing.ts
+++ /dev/null
@@ -1,191 +0,0 @@
-import { promises as fs } from "fs";
-import path from "path";
-import os from "os";
-import type {
- PairingRequest,
- PairingListResult,
- PairingApproveResult,
- DirectResult,
-} from "./types";
-import { getConfig, patchConfig, type SquadhubConnection } from "./client";
-
-const SQUADHUB_STATE_DIR =
- process.env.SQUADHUB_STATE_DIR || path.join(os.homedir(), ".squadhub");
-const CREDENTIALS_DIR = path.join(SQUADHUB_STATE_DIR, "credentials");
-
-type PairingStore = {
- version: 1;
- requests: PairingRequest[];
-};
-
-const PAIRING_PENDING_TTL_MS = 60 * 60 * 1000; // 1 hour
-
-function resolvePairingPath(channel: string): string {
- // Sanitize channel name for filesystem
- const safe = channel
- .trim()
- .toLowerCase()
- .replace(/[\\/:*?"<>|]/g, "_");
- return path.join(CREDENTIALS_DIR, `${safe}-pairing.json`);
-}
-
-async function readJsonFile(filePath: string, fallback: T): Promise {
- try {
- const raw = await fs.readFile(filePath, "utf-8");
- return JSON.parse(raw) as T;
- } catch {
- return fallback;
- }
-}
-
-async function writeJsonFile(filePath: string, value: unknown): Promise {
- const dir = path.dirname(filePath);
- await fs.mkdir(dir, { recursive: true, mode: 0o700 });
- await fs.writeFile(filePath, JSON.stringify(value, null, 2), "utf-8");
- await fs.chmod(filePath, 0o600);
-}
-
-function isExpired(entry: PairingRequest, nowMs: number): boolean {
- const createdAt = Date.parse(entry.createdAt);
- if (!Number.isFinite(createdAt)) return true;
- return nowMs - createdAt > PAIRING_PENDING_TTL_MS;
-}
-
-function pruneExpiredRequests(reqs: PairingRequest[], nowMs: number) {
- return reqs.filter((req) => !isExpired(req, nowMs));
-}
-
-export async function listChannelPairingRequests(
- channel: string,
-): Promise> {
- try {
- const filePath = resolvePairingPath(channel);
- const store = await readJsonFile(filePath, {
- version: 1,
- requests: [],
- });
-
- const nowMs = Date.now();
- const requests = pruneExpiredRequests(store.requests || [], nowMs);
-
- return { ok: true, result: { requests } };
- } catch (error) {
- return {
- ok: false,
- error: {
- type: "read_error",
- message:
- error instanceof Error
- ? error.message
- : "Failed to read pairing requests",
- },
- };
- }
-}
-
-export async function approveChannelPairingCode(
- connection: SquadhubConnection,
- channel: string,
- code: string,
-): Promise> {
- try {
- if (!code) {
- return {
- ok: false,
- error: { type: "invalid_code", message: "Pairing code is required" },
- };
- }
-
- const normalizedCode = code.trim().toUpperCase();
- const pairingPath = resolvePairingPath(channel);
-
- // Read current pairing requests (file-based - squadhub writes these)
- const store = await readJsonFile(pairingPath, {
- version: 1,
- requests: [],
- });
-
- const nowMs = Date.now();
- const requests = pruneExpiredRequests(store.requests || [], nowMs);
-
- // Find the request with matching code
- const entry = requests.find((r) => r.code.toUpperCase() === normalizedCode);
-
- if (!entry) {
- return {
- ok: false,
- error: {
- type: "not_found",
- message: "Invalid or expired pairing code",
- },
- };
- }
-
- // Get current config to read existing allowFrom list
- const configResult = await getConfig(connection);
- if (!configResult.ok) {
- return {
- ok: false,
- error: {
- type: "config_error",
- message: "Failed to read current config",
- },
- };
- }
-
- // Extract existing allowFrom list
- const config = configResult.result.details.config as {
- channels?: {
- [key: string]: {
- allowFrom?: string[];
- };
- };
- };
- const existingAllowFrom = config?.channels?.[channel]?.allowFrom ?? [];
-
- // Add user ID to allowFrom if not already present
- if (!existingAllowFrom.includes(entry.id)) {
- const patchResult = await patchConfig(
- connection,
- {
- channels: {
- [channel]: {
- allowFrom: [...existingAllowFrom, entry.id],
- },
- },
- },
- configResult.result.details.hash,
- );
-
- if (!patchResult.ok) {
- return {
- ok: false,
- error: {
- type: "config_error",
- message: "Failed to update allowFrom config",
- },
- };
- }
- }
-
- // Remove from pending requests file
- const remainingRequests = requests.filter((r) => r.id !== entry.id);
- await writeJsonFile(pairingPath, {
- version: 1,
- requests: remainingRequests,
- });
-
- return { ok: true, result: { id: entry.id, approved: true } };
- } catch (error) {
- return {
- ok: false,
- error: {
- type: "write_error",
- message:
- error instanceof Error
- ? error.message
- : "Failed to approve pairing code",
- },
- };
- }
-}
diff --git a/packages/shared/src/squadhub/types.ts b/packages/shared/src/squadhub/types.ts
index 3ed726d..0f231bd 100644
--- a/packages/shared/src/squadhub/types.ts
+++ b/packages/shared/src/squadhub/types.ts
@@ -14,11 +14,6 @@ export type ToolResult =
| { ok: true; result: AgentToolResult }
| { ok: false; error: { type: string; message: string } };
-// DirectResult for operations that don't go through squadhub tools
-export type DirectResult =
- | { ok: true; result: T }
- | { ok: false; error: { type: string; message: string } };
-
export type ConfigGetResult = {
config: Record;
hash: string;
@@ -69,12 +64,3 @@ export type PairingRequest = {
lastSeenAt: string;
meta?: Record;
};
-
-export type PairingListResult = {
- requests: PairingRequest[];
-};
-
-export type PairingApproveResult = {
- id: string;
- approved: boolean;
-};
diff --git a/packages/squadhub-extensions/openclaw.plugin.json b/packages/squadhub-extensions/openclaw.plugin.json
new file mode 100644
index 0000000..173bdf7
--- /dev/null
+++ b/packages/squadhub-extensions/openclaw.plugin.json
@@ -0,0 +1,8 @@
+{
+ "id": "squadhub-extensions",
+ "configSchema": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {}
+ }
+}
diff --git a/packages/squadhub-extensions/package.json b/packages/squadhub-extensions/package.json
new file mode 100644
index 0000000..efd8ccd
--- /dev/null
+++ b/packages/squadhub-extensions/package.json
@@ -0,0 +1,28 @@
+{
+ "name": "@clawe/squadhub-extensions",
+ "version": "0.1.0",
+ "description": "OpenClaw plugin extensions for Clawe squadhub",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build": "tsup",
+ "check-types": "tsc --noEmit"
+ },
+ "openclaw": {
+ "extensions": [
+ "./dist/index.js"
+ ]
+ },
+ "devDependencies": {
+ "@clawe/typescript-config": "workspace:*",
+ "@sinclair/typebox": "^0.34.0",
+ "@types/node": "^22.0.0",
+ "tsup": "^8.0.0",
+ "typescript": "^5.7.3",
+ "vitest": "^4.0.0"
+ },
+ "files": [
+ "dist",
+ "openclaw.plugin.json"
+ ]
+}
diff --git a/packages/squadhub-extensions/src/index.ts b/packages/squadhub-extensions/src/index.ts
new file mode 100644
index 0000000..833f686
--- /dev/null
+++ b/packages/squadhub-extensions/src/index.ts
@@ -0,0 +1,6 @@
+import type { OpenClawPluginApi } from "./types";
+import { registerPairingTool } from "./tools/pairing";
+
+export default function register(api: OpenClawPluginApi) {
+ registerPairingTool(api);
+}
diff --git a/packages/squadhub-extensions/src/tools/pairing.ts b/packages/squadhub-extensions/src/tools/pairing.ts
new file mode 100644
index 0000000..bbf60d4
--- /dev/null
+++ b/packages/squadhub-extensions/src/tools/pairing.ts
@@ -0,0 +1,200 @@
+import { promises as fs } from "node:fs";
+import path from "node:path";
+import { Type } from "@sinclair/typebox";
+import type { OpenClawPluginApi, ToolResult } from "../types";
+
+/**
+ * Pairing tool for Clawe.
+ *
+ * Exposes channel pairing operations (list pending requests, approve by code)
+ * as an OpenClaw tool callable via POST /tools/invoke.
+ *
+ * File layout (relative to $OPENCLAW_STATE_DIR):
+ * credentials/-pairing.json — pending pairing requests
+ * credentials/-allowFrom.json — approved sender IDs
+ */
+
+const PAIRING_PENDING_TTL_MS = 60 * 60 * 1000; // 1 hour
+
+type PairingRequest = {
+ id: string;
+ code: string;
+ createdAt: string;
+ lastSeenAt: string;
+ meta?: Record;
+};
+
+type PairingStore = {
+ version: 1;
+ requests: PairingRequest[];
+};
+
+type AllowFromStore = {
+ version: 1;
+ allowFrom: string[];
+};
+
+function resolveStateDir(): string {
+ return process.env.OPENCLAW_STATE_DIR || "/data/config";
+}
+
+function safeChannelKey(channel: string): string {
+ const raw = channel.trim().toLowerCase();
+ if (!raw) throw new Error("invalid pairing channel");
+ const safe = raw.replace(/[\\/:*?"<>|]/g, "_").replace(/\.\./g, "_");
+ if (!safe || safe === "_") throw new Error("invalid pairing channel");
+ return safe;
+}
+
+function resolvePairingPath(channel: string): string {
+ return path.join(
+ resolveStateDir(),
+ "credentials",
+ `${safeChannelKey(channel)}-pairing.json`,
+ );
+}
+
+function resolveAllowFromPath(channel: string): string {
+ return path.join(
+ resolveStateDir(),
+ "credentials",
+ `${safeChannelKey(channel)}-allowFrom.json`,
+ );
+}
+
+async function readJsonFile(filePath: string, fallback: T): Promise {
+ try {
+ const raw = await fs.readFile(filePath, "utf-8");
+ return JSON.parse(raw) as T;
+ } catch {
+ return fallback;
+ }
+}
+
+async function writeJsonFile(filePath: string, value: unknown): Promise {
+ const dir = path.dirname(filePath);
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
+ await fs.writeFile(filePath, JSON.stringify(value, null, 2), "utf-8");
+ await fs.chmod(filePath, 0o600);
+}
+
+function isExpired(entry: PairingRequest, nowMs: number): boolean {
+ const createdAt = Date.parse(entry.createdAt);
+ if (!Number.isFinite(createdAt)) return true;
+ return nowMs - createdAt > PAIRING_PENDING_TTL_MS;
+}
+
+function pruneExpired(reqs: PairingRequest[], nowMs: number): PairingRequest[] {
+ return reqs.filter((r) => !isExpired(r, nowMs));
+}
+
+function textResult(data: unknown): ToolResult {
+ return {
+ content: [{ type: "text", text: JSON.stringify(data) }],
+ details:
+ typeof data === "object" && data !== null
+ ? (data as Record)
+ : {},
+ };
+}
+
+function errorResult(message: string): ToolResult {
+ return {
+ content: [
+ { type: "text", text: JSON.stringify({ ok: false, error: message }) },
+ ],
+ details: { ok: false, error: message },
+ };
+}
+
+async function listAction(channel: string): Promise {
+ const filePath = resolvePairingPath(channel);
+ const store = await readJsonFile(filePath, {
+ version: 1,
+ requests: [],
+ });
+ const requests = pruneExpired(store.requests || [], Date.now());
+ return textResult({ ok: true, requests });
+}
+
+async function approveAction(
+ channel: string,
+ code: string,
+): Promise {
+ if (!code) return errorResult("Pairing code is required");
+
+ const normalizedCode = code.trim().toUpperCase();
+ const pairingPath = resolvePairingPath(channel);
+
+ // Read pending requests
+ const store = await readJsonFile(pairingPath, {
+ version: 1,
+ requests: [],
+ });
+ const requests = pruneExpired(store.requests || [], Date.now());
+
+ // Find matching request
+ const entry = requests.find((r) => r.code.toUpperCase() === normalizedCode);
+ if (!entry) return errorResult("Invalid or expired pairing code");
+
+ // Add to allow-from store
+ const allowFromPath = resolveAllowFromPath(channel);
+ const allowFromStore = await readJsonFile(allowFromPath, {
+ version: 1,
+ allowFrom: [],
+ });
+ const existing = allowFromStore.allowFrom || [];
+ if (!existing.includes(entry.id)) {
+ await writeJsonFile(allowFromPath, {
+ version: 1,
+ allowFrom: [...existing, entry.id],
+ } satisfies AllowFromStore);
+ }
+
+ // Remove approved request from pending
+ const remaining = requests.filter((r) => r.id !== entry.id);
+ await writeJsonFile(pairingPath, {
+ version: 1,
+ requests: remaining,
+ } satisfies PairingStore);
+
+ return textResult({ ok: true, id: entry.id, approved: true });
+}
+
+export function registerPairingTool(api: OpenClawPluginApi) {
+ api.registerTool({
+ name: "clawe_pairing",
+ description:
+ "Manage channel pairing requests (list pending, approve by code)",
+ parameters: Type.Object({
+ action: Type.Union([Type.Literal("list"), Type.Literal("approve")], {
+ description: 'Action to perform: "list" or "approve"',
+ }),
+ channel: Type.String({ description: 'Channel name (e.g. "telegram")' }),
+ code: Type.Optional(
+ Type.String({ description: "Pairing code (required for approve)" }),
+ ),
+ }),
+ async execute(
+ _toolCallId: string,
+ params: Record,
+ ): Promise {
+ const action = params.action as string;
+ const channel = params.channel as string;
+
+ if (!channel) return errorResult("Channel is required");
+
+ if (action === "list") {
+ return listAction(channel);
+ }
+
+ if (action === "approve") {
+ const code = params.code as string | undefined;
+ if (!code) return errorResult("Code is required for approve action");
+ return approveAction(channel, code);
+ }
+
+ return errorResult(`Unknown action: ${action}`);
+ },
+ });
+}
diff --git a/packages/squadhub-extensions/src/types.ts b/packages/squadhub-extensions/src/types.ts
new file mode 100644
index 0000000..4d3340a
--- /dev/null
+++ b/packages/squadhub-extensions/src/types.ts
@@ -0,0 +1,32 @@
+import type { TObject } from "@sinclair/typebox";
+
+/**
+ * Minimal type definitions for the OpenClaw plugin API.
+ * Only the surface we actually use — avoids importing from openclaw.
+ */
+
+export type ToolResult = {
+ content: Array<{ type: "text"; text: string }>;
+ details?: Record;
+};
+
+export type AgentTool = {
+ name: string;
+ description: string;
+ parameters: TObject;
+ execute: (
+ toolCallId: string,
+ params: Record,
+ ) => Promise;
+};
+
+export type OpenClawPluginApi = {
+ id: string;
+ name: string;
+ config: Record;
+ pluginConfig?: Record;
+ registerTool: (
+ tool: AgentTool,
+ opts?: { name?: string; optional?: boolean },
+ ) => void;
+};
diff --git a/packages/squadhub-extensions/tsconfig.json b/packages/squadhub-extensions/tsconfig.json
new file mode 100644
index 0000000..5bb6f44
--- /dev/null
+++ b/packages/squadhub-extensions/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "extends": "@clawe/typescript-config/base.json",
+ "compilerOptions": {
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "types": ["node"],
+ "rootDir": "./src",
+ "outDir": "./dist",
+ "declaration": true,
+ "declarationMap": true
+ },
+ "include": ["src/**/*.ts"],
+ "exclude": ["node_modules", "**/*.spec.ts"]
+}
diff --git a/packages/squadhub-extensions/tsup.config.ts b/packages/squadhub-extensions/tsup.config.ts
new file mode 100644
index 0000000..d2754f4
--- /dev/null
+++ b/packages/squadhub-extensions/tsup.config.ts
@@ -0,0 +1,12 @@
+import { defineConfig } from "tsup";
+
+export default defineConfig({
+ entry: { index: "src/index.ts" },
+ format: ["esm"],
+ target: "node22",
+ platform: "node",
+ outDir: "dist",
+ clean: true,
+ bundle: true,
+ noExternal: [/.*/],
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1049082..5e9e7fc 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -344,6 +344,27 @@ importers:
specifier: ^4.0.18
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.15.3)(jiti@2.6.1)(jsdom@28.0.0)(lightningcss@1.30.2)(tsx@4.21.0)
+ packages/squadhub-extensions:
+ devDependencies:
+ '@clawe/typescript-config':
+ specifier: workspace:*
+ version: link:../typescript-config
+ '@sinclair/typebox':
+ specifier: ^0.34.0
+ version: 0.34.48
+ '@types/node':
+ specifier: ^22.0.0
+ version: 22.15.3
+ tsup:
+ specifier: ^8.0.0
+ version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.2)
+ typescript:
+ specifier: ^5.7.3
+ version: 5.9.2
+ vitest:
+ specifier: ^4.0.0
+ version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.15.3)(jiti@2.6.1)(jsdom@28.0.0)(lightningcss@1.30.2)(tsx@4.21.0)
+
packages/typescript-config: {}
packages/ui:
@@ -2192,6 +2213,9 @@ packages:
cpu: [x64]
os: [win32]
+ '@sinclair/typebox@0.34.48':
+ resolution: {integrity: sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==}
+
'@smithy/abort-controller@4.2.8':
resolution: {integrity: sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw==}
engines: {node: '>=18.0.0'}
@@ -4286,6 +4310,7 @@ packages:
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
@@ -7376,6 +7401,8 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.57.1':
optional: true
+ '@sinclair/typebox@0.34.48': {}
+
'@smithy/abort-controller@4.2.8':
dependencies:
'@smithy/types': 4.12.0