Skill: haojian/personal-data-hub (version 0.1.0)

--- name: PersonalDataHub description: Pull personal data (emails, issues) and propose outbound actions (drafts, replies) through the PersonalDataHub access control gateway. Data is filtered, redacted, and shaped by the owner's policy before reaching the agent. version: 0.1.0 skillKey: personaldatahub emoji: 🔐 homepage: https://github.com/AISmithLab/PersonalDataHub os: darwin, linux, win32 install: cd ../../ && pnpm install && pnpm build && npx pdh init "OpenClaw Agent" && npx pdh start metadata: {} always: false --- # PersonalDataHub Access personal data from Gmail, GitHub, and other sources through the PersonalDataHub access control gateway. The data owner controls what the agent can see, which fields are visible, what gets redacted, and which actions are allowed. ## Tools ### personal_data_pull Pull data from a source. Data is filtered and redacted according to the owner's access control policy. **Parameters:** - `source` (required) — The data source (e.g., `"gmail"`, `"github"`) - `purpose` (required) — Why this data is needed (logged for audit) - `type` (optional) — Data type (e.g., `"email"`, `"issue"`) - `query` (optional) — Search query in source-native syntax (e.g., `"is:unread from:alice"`) - `limit` (optional) — Maximum number of results **Example:** ``` Pull my recent unread emails about the Q4 report. ``` ### personal_data_propose Propose an outbound action (e.g., draft email). The action is staged for the data owner to review and approve — it does NOT execute until approved. **Parameters:** - `source` (required) — The source service (e.g., `"gmail"`) - `action_type` (required) — Action type (e.g., `"draft_email"`, `"send_email"`, `"reply_email"`) - `to` (required) — Recipient email address - `subject` (required) — Email subject - `body` (required) — Email body - `purpose` (required) — Why this action is being proposed (logged for audit) - `in_reply_to` (optional) — Message ID for threading **Example:** ``` Draft a reply to Alice's Q4 report email thanking her for the numbers. ``` ## Direct API Fallback If the tools above are not available, you can call the PersonalDataHub API directly via HTTP. **Credentials:** Read `~/.pdh/credentials.json` to get `hubUrl` and `apiKey`. **Pull data:** ```bash curl -X POST <hubUrl>/app/v1/pull \ -H "Authorization: Bearer <apiKey>" \ -H "Content-Type: application/json" \ -d '{"source": "gmail", "purpose": "reason for pulling data"}' ``` **Propose an action:** ```bash curl -X POST <hubUrl>/app/v1/propose \ -H "Authorization: Bearer <apiKey>" \ -H "Content-Type: application/json" \ -d '{"source": "gmail", "action_type": "draft_email", "action_data": {"to": "...", "subject": "...", "body": "..."}, "purpose": "reason for action"}' ``` ## Troubleshooting If calls fail, check if the PersonalDataHub server is running: ```bash curl <hubUrl>/health ``` If the server is not running, find and start it: ```bash # Check where PersonalDataHub is installed cat ~/.pdh/credentials.json # look at hubDir # Start the server cd <hubDir> && node dist/index.js ``` ## Setup The install hook bootstraps PersonalDataHub automatically: 1. Installs dependencies, builds the project, initializes the database 2. Creates an API key and saves it to `~/.pdh/credentials.json` 3. Starts the server in the background After installation, open `http://localhost:3000` to connect Gmail/GitHub via OAuth. Agents read credentials automatically from `~/.pdh/credentials.json` — no manual configuration needed. ## Query Syntax (Gmail) - `is:unread` — unread emails - `from:alice` — emails from Alice - `newer_than:7d` — emails from the last 7 days - `subject:report` — emails with "report" in subject - Combine: `is:unread from:alice newer_than:7d` ## Important Notes - **Data is filtered**: The owner controls which fields you see. Some fields may be missing or redacted. - **Actions require approval**: All outbound actions (emails, drafts) go to a staging queue. The owner must approve before execution. - **Everything is audited**: Every pull and propose is logged with your purpose string.

path: openclaw.plugin.json
content: {
  "id": "personal-data-hub",
  "name": "Personal Data Hub",
  "description": "Unified interface to personal data through PersonalDataHub access control gateway",
  "configSchema": {
    "type": "object",
    "additionalProperties": false,
    "properties": {
      "hubUrl": {
        "type": "string",
        "description": "Base URL of the PersonalDataHub (e.g., http://localhost:7007)"
      },
      "apiKey": {
        "type": "string",
        "description": "API key for authenticating with the PersonalDataHub"
      }
    },
    "required": ["hubUrl", "apiKey"]
  },
  "uiHints": {
    "hubUrl": {
      "label": "PersonalDataHub URL",
      "help": "The base URL where PersonalDataHub is running"
    },
    "apiKey": {
      "label": "API Key",
      "help": "API key generated from PersonalDataHub GUI settings",
      "sensitive": true
    }
  }
}


path: package.json
content: {
  "name": "@openclaw/personal-data-hub",
  "version": "0.1.0",
  "private": true,
  "description": "OpenClaw skill for interacting with PersonalDataHub",
  "type": "module",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": "./dist/index.js",
    "./setup": "./dist/setup.js"
  },
  "scripts": {
    "build": "tsc",
    "test": "vitest run"
  },
  "devDependencies": {
    "typescript": "^5.8.3",
    "vitest": "^4.0.18",
    "@sinclair/typebox": "^0.34.33"
  },
  "openclaw": {
    "extensions": ["./dist/index.js"]
  }
}


path: src/hub-client.test.ts
content: import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { HubClient, HubApiError } from './hub-client.js';

describe('HubClient', () => {
  let client: HubClient;
  let mockFetch: ReturnType<typeof vi.fn>;

  beforeEach(() => {
    mockFetch = vi.fn();
    client = new HubClient({
      hubUrl: 'http://localhost:7007',
      [REDACTED_SECRET]',
    });
    vi.stubGlobal('fetch', mockFetch);
  });

  afterEach(() => {
    vi.restoreAllMocks();
  });

  describe('pull', () => {
    it('sends correct HTTP request with purpose field', async () => {
      const mockResponse = {
        ok: true,
        data: [
          {
            source: 'gmail',
            source_item_id: 'msg_1',
            type: 'email',
            timestamp: '2026-01-15T10:00:00Z',
            data: { title: 'Test Email', body: 'Hello' },
          },
        ],
      };

      mockFetch.mockResolvedValue({
        ok: true,
        json: () => Promise.resolve(mockResponse),
      });

      const result = await client.pull({
        source: 'gmail',
        type: 'email',
        params: { query: 'is:unread' },
        purpose: 'Collect unanswered emails to draft responses',
      });

      expect(mockFetch).toHaveBeenCalledWith('http://localhost:7007/app/v1/pull', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': 'Bearer pk_test_abc123',
        },
        body: JSON.stringify({
          source: 'gmail',
          type: 'email',
          params: { query: 'is:unread' },
          purpose: 'Collect unanswered emails to draft responses',
        }),
      });

      expect(result.ok).toBe(true);
      expect(result.data).toHaveLength(1);
      expect(result.data[0].data.title).toBe('Test Email');
    });

    it('sends pull request without optional fields', async () => {
      mockFetch.mockResolvedValue({
        ok: true,
        json: () => Promise.resolve({ ok: true, data: [] }),
      });

      await client.pull({
        source: 'gmail',
        purpose: 'Check for new emails',
      });

      const body = JSON.parse(mockFetch.mock.calls[0][1].body);
      expect(body.source).toBe('gmail');
      expect(body.purpose).toBe('Check for new emails');
      expect(body.type).toBeUndefined();
      expect(body.params).toBeUndefined();
    });

    it('throws HubApiError on non-ok response', async () => {
      mockFetch.mockResolvedValue({
        ok: false,
        status: 401,
        text: () => Promise.resolve('Unauthorized'),
      });

      await expect(
        client.pull({ source: 'gmail', purpose: 'test' }),
      ).rejects.toThrow(HubApiError);

      try {
        await client.pull({ source: 'gmail', purpose: 'test' });
      } catch (e) {
        const err = e as HubApiError;
        expect(err.endpoint).toBe('pull');
        expect(err.statusCode).toBe(401);
        expect(err.body).toBe('Unauthorized');
      }
    });

    it('strips trailing slash from hub URL', async () => {
      const clientWithSlash = new HubClient({
        hubUrl: 'http://localhost:7007/',
        [REDACTED_SECRET]',
      });

      mockFetch.mockResolvedValue({
        ok: true,
        json: () => Promise.resolve({ ok: true, data: [] }),
      });

      await clientWithSlash.pull({ source: 'gmail', purpose: 'test' });

      expect(mockFetch.mock.calls[0][0]).toBe('http://localhost:7007/app/v1/pull');
    });
  });

  describe('propose', () => {
    it('sends correct HTTP request with purpose field', async () => {
      const mockResponse = {
        ok: true,
        actionId: 'act_123',
        status: 'pending_review',
      };

      mockFetch.mockResolvedValue({
        ok: true,
        json: () => Promise.resolve(mockResponse),
      });

      const result = await client.propose({
        source: 'gmail',
        action_type: 'draft_email',
        action_data: {
          to: '[REDACTED_SECRET]',
          subject: 'Re: Q4 Report',
          body: 'Thanks Alice, the numbers look good.',
          in_reply_to: 'msg_abc123',
        },
        purpose: 'Draft reply to unanswered email from Alice about Q4 report',
      });

      expect(mockFetch).toHaveBeenCalledWith('http://localhost:7007/app/v1/propose', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': 'Bearer pk_test_abc123',
        },
        body: JSON.stringify({
          source: 'gmail',
          action_type: 'draft_email',
          action_data: {
            to: '[REDACTED_SECRET]',
            subject: 'Re: Q4 Report',
            body: 'Thanks Alice, the numbers look good.',
            in_reply_to: 'msg_abc123',
          },
          purpose: 'Draft reply to unanswered email from Alice about Q4 report',
        }),
      });

      expect(result.ok).toBe(true);
      expect(result.actionId).toBe('act_123');
      expect(result.status).toBe('pending_review');
    });

    it('throws HubApiError on non-ok response', async () => {
      mockFetch.mockResolvedValue({
        ok: false,
        status: 403,
        text: () => Promise.resolve('Action type not allowed'),
      });

      await expect(
        client.propose({
          source: 'gmail',
          action_type: 'send_email',
          action_data: { to: '[REDACTED_SECRET]', subject: 'Hi', body: 'Hey' },
          purpose: 'test',
        }),
      ).rejects.toThrow(HubApiError);
    });
  });
});


path: src/hub-client.ts
content: /**
 * Thin HTTP client wrapping the PersonalDataHub App API endpoints.
 * The skill does not know about manifests or policies — it simply
 * sends requests with a `purpose` string. The Hub resolves the policy internally.
 */

export interface PullParams {
  source: string;
  type?: string;
  params?: Record<string, unknown>;
  purpose: string;
}

export interface ProposeParams {
  source: string;
  action_type: string;
  action_data: Record<string, unknown>;
  purpose: string;
}

export interface PullResult {
  ok: boolean;
  data: Array<{
    source: string;
    source_item_id: string;
    type: string;
    timestamp: string;
    data: Record<string, unknown>;
  }>;
  meta?: Record<string, unknown>;
}

export interface ProposeResult {
  ok: boolean;
  actionId: string;
  status: string;
}

export interface HubClientConfig {
  hubUrl: string;
  [REDACTED_SECRET];
}

export class HubClient {
  private hubUrl: string;
  private [REDACTED_SECRET];

  constructor(config: HubClientConfig) {
    this.hubUrl = config.hubUrl.replace(/\/+$/, '');
    this.[REDACTED_SECRET];
  }

  /**
   * Pull data from a source through PersonalDataHub.
   * The Hub applies the owner's manifest/policy to filter, redact, and shape the data.
   */
  async pull(params: PullParams): Promise<PullResult> {
    const res = await fetch(`${this.hubUrl}/app/v1/pull`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${this.apiKey}`,
      },
      body: JSON.stringify(params),
    });

    if (!res.ok) {
      const text = await res.text();
      throw new HubApiError('pull', res.status, text);
    }

    return res.json() as Promise<PullResult>;
  }

  /**
   * Propose an outbound action through PersonalDataHub staging.
   * The action goes to the owner's staging queue for review before execution.
   */
  async propose(params: ProposeParams): Promise<ProposeResult> {
    const res = await fetch(`${this.hubUrl}/app/v1/propose`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${this.apiKey}`,
      },
      body: JSON.stringify(params),
    });

    if (!res.ok) {
      const text = await res.text();
      throw new HubApiError('propose', res.status, text);
    }

    return res.json() as Promise<ProposeResult>;
  }
}

export class HubApiError extends Error {
  constructor(
    public readonly endpoint: string,
    public readonly statusCode: number,
    public readonly body: string,
  ) {
    super(`PersonalDataHub API error on ${endpoint}: ${statusCode} - ${body}`);
    this.name = 'HubApiError';
  }
}


path: src/index.test.ts
content: import { describe, it, expect, vi, afterEach } from 'vitest';

// Mock readCredentials so tests don't depend on real ~/.pdh/credentials.json
vi.mock('./setup.js', async (importOriginal) => {
  const actual = await importOriginal() as Record<string, unknown>;
  return {
    ...actual,
    readCredentials: vi.fn(() => null),
  };
});

import plugin from './index.js';

describe('PersonalDataHub Plugin', () => {
  afterEach(() => {
    vi.restoreAllMocks();
  });

  it('has correct plugin metadata', () => {
    expect(plugin.id).toBe('personal-data-hub');
    expect(plugin.name).toBe('Personal Data Hub');
    expect(plugin.description).toContain('PersonalDataHub');
  });

  it('config schema validates correct config', () => {
    const result = plugin.configSchema.safeParse({
      hubUrl: 'http://localhost:7007',
      [REDACTED_SECRET]',
    });
    expect(result.success).toBe(true);
  });

  it('config schema rejects missing hubUrl', () => {
    const result = plugin.configSchema.safeParse({
      [REDACTED_SECRET]',
    });
    expect(result.success).toBe(false);
  });

  it('config schema rejects missing apiKey', () => {
    const result = plugin.configSchema.safeParse({
      hubUrl: 'http://localhost:7007',
    });
    expect(result.success).toBe(false);
  });

  it('config schema rejects non-object', () => {
    const result = plugin.configSchema.safeParse('not an object');
    expect(result.success).toBe(false);
  });

  it('registers tools when config is valid', async () => {
    const registerTool = vi.fn();
    const on = vi.fn();
    const api = {
      pluginConfig: {
        hubUrl: 'http://localhost:7007',
        [REDACTED_SECRET]',
      },
      logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
      registerTool,
      on,
    };

    await plugin.register(api);

    expect(registerTool).toHaveBeenCalledTimes(2);

    // Verify pull tool
    const pullTool = registerTool.mock.calls[0][0];
    expect(pullTool.name).toBe('personal_data_pull');

    // Verify propose tool
    const proposeTool = registerTool.mock.calls[1][0];
    expect(proposeTool.name).toBe('personal_data_propose');

    // Verify hook registration
    expect(on).toHaveBeenCalledWith('before_agent_start', expect.any(Function));
  });

  it('before_agent_start hook returns system prompt', async () => {
    const on = vi.fn();
    const api = {
      pluginConfig: {
        hubUrl: 'http://localhost:7007',
        [REDACTED_SECRET]',
      },
      logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
      registerTool: vi.fn(),
      on,
    };

    await plugin.register(api);

    // Get the registered hook handler
    const hookCall = on.mock.calls.find((c: unknown[]) => c[0] === 'before_agent_start');
    expect(hookCall).toBeDefined();

    const handler = hookCall![1] as (event: unknown) => Promise<{ systemPromptAppend: string }>;
    const result = await handler({});
    expect(result.systemPromptAppend).toContain('PersonalDataHub');
    expect(result.systemPromptAppend).toContain('personal_data_pull');
    expect(result.systemPromptAppend).toContain('personal_data_propose');
  });

  describe('auto-setup', () => {
    it('auto-discovers hub and creates API key when config is empty', async () => {
      const registerTool = vi.fn();
      const on = vi.fn();
      const mockFetch = vi.fn();
      vi.stubGlobal('fetch', mockFetch);

      // discoverHub probes localhost:3000 — succeeds
      mockFetch
        .mockResolvedValueOnce({
          ok: true,
          json: () => Promise.resolve({ ok: true, version: '0.1.0' }),
        })
        // checkHub called again during auto-setup
        .mockResolvedValueOnce({
          ok: true,
          json: () => Promise.resolve({ ok: true, version: '0.1.0' }),
        })
        // createApiKey
        .mockResolvedValueOnce({
          ok: true,
          json: () => Promise.resolve({ ok: true, id: 'openclaw-agent', key: 'pk_auto123' }),
        });

      const api = {
        pluginConfig: undefined,
        logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
        registerTool,
        on,
      };

      await plugin.register(api);

      expect(registerTool).toHaveBeenCalledTimes(2);
      expect(api.logger.info).toHaveBeenCalledWith(
        expect.stringContaining('Auto-created API key'),
      );
    });

    it('falls back to warning when no hub is running', async () => {
      const registerTool = vi.fn();
      const on = vi.fn();
      const mockFetch = vi.fn();
      vi.stubGlobal('fetch', mockFetch);

      // All discovery attempts fail
      mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));

      const api = {
        pluginConfig: undefined,
        logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
        registerTool,
        on,
      };

      await plugin.register(api);

      expect(registerTool).not.toHaveBeenCalled();
      expect(api.logger.warn).toHaveBeenCalledWith(
        expect.stringContaining('npx pdh init'),
      );
    });

    it('creates API key when hubUrl provided but apiKey missing', async () => {
      const registerTool = vi.fn();
      const on = vi.fn();
      const mockFetch = vi.fn();
      vi.stubGlobal('fetch', mockFetch);

      // checkHub
      mockFetch
        .mockResolvedValueOnce({
          ok: true,
          json: () => Promise.resolve({ ok: true }),
        })
        // createApiKey
        .mockResolvedValueOnce({
          ok: true,
          json: () => Promise.resolve({ ok: true, id: 'openclaw', key: 'pk_new456' }),
        });

      const api = {
        pluginConfig: { hubUrl: 'http://localhost:3000' },
        logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
        registerTool,
        on,
      };

      await plugin.register(api);

      expect(registerTool).toHaveBeenCalledTimes(2);
    });

    it('uses credentials file when pluginConfig and env vars are missing', async () => {
      const { readCredentials } = await import('./setup.js');
      const mockedReadCredentials = vi.mocked(readCredentials);
      mockedReadCredentials.mockReturnValueOnce({
        hubUrl: 'http://localhost:5555',
        [REDACTED_SECRET]',
      });

      const registerTool = vi.fn();
      const on = vi.fn();
      const api = {
        pluginConfig: undefined,
        logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
        registerTool,
        on,
      };

      await plugin.register(api);

      expect(registerTool).toHaveBeenCalledTimes(2);
      expect(api.logger.info).toHaveBeenCalledWith(
        expect.stringContaining('Configured from credentials file'),
      );
    });

    it('uses environment variables when pluginConfig is missing (ClawHub pattern)', async () => {
      process.env.PDH_HUB_URL = 'http://localhost:9999';
      process.env.PDH_API_KEY = 'pk_env_test_key';

      const registerTool = vi.fn();
      const on = vi.fn();
      const api = {
        pluginConfig: undefined,
        logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
        registerTool,
        on,
      };

      await plugin.register(api);

      expect(registerTool).toHaveBeenCalledTimes(2);
      expect(api.logger.info).toHaveBeenCalledWith(
        expect.stringContaining('Configured from environment variables'),
      );

      delete process.env.PDH_HUB_URL;
      delete process.env.PDH_API_KEY;
    });

    it('warns with missing config when no config is passed (legacy behavior)', async () => {
      // Stub fetch so discoverHub fails
      const mockFetch = vi.fn();
      vi.stubGlobal('fetch', mockFetch);
      mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));

      const registerTool = vi.fn();
      const on = vi.fn();
      const api = {
        pluginConfig: undefined,
        logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
        registerTool,
        on,
      };

      await plugin.register(api);

      expect(registerTool).not.toHaveBeenCalled();
      expect(api.logger.warn).toHaveBeenCalledWith(
        expect.stringContaining('Missing hubUrl or apiKey'),
      );
    });
  });
});


path: src/index.ts
content: /**
 * PersonalDataHub — OpenClaw skill for interacting with PersonalDataHub.
 *
 * Registers tools that let the agent pull personal data and propose
 * outbound actions through the PersonalDataHub access control gateway.
 *
 * Config resolution order:
 *   1. Plugin config (hubUrl + apiKey passed directly)
 *   2. Environment variables (PDH_HUB_URL + PDH_API_KEY)
 *   3. Credentials file (~/.pdh/credentials.json, written by npx pdh init)
 *   4. Auto-discovery (probe localhost, create API key)
 */

import { HubClient } from './hub-client.js';
import { createPullTool, createProposeTool } from './tools.js';
import { PERSONAL_DATA_SYSTEM_PROMPT } from './prompts.js';
import { discoverHub, checkHub, createApiKey, readCredentials } from './setup.js';

export interface PersonalDataHubPluginConfig {
  hubUrl: string;
  [REDACTED_SECRET];
}

export default {
  id: 'personal-data-hub',
  name: 'Personal Data Hub',
  description: 'Unified interface to personal data through PersonalDataHub access control gateway',

  configSchema: {
    safeParse(value: unknown) {
      if (!value || typeof value !== 'object' || Array.isArray(value)) {
        return {
          success: false as const,
          error: { issues: [{ path: [], message: 'expected config object' }] },
        };
      }
      const cfg = value as Record<string, unknown>;
      if (!cfg.hubUrl || typeof cfg.hubUrl !== 'string') {
        return {
          success: false as const,
          error: { issues: [{ path: ['hubUrl'], message: 'hubUrl is required and must be a string' }] },
        };
      }
      if (!cfg.apiKey || typeof cfg.apiKey !== 'string') {
        return {
          success: false as const,
          error: { issues: [{ path: ['apiKey'], message: 'apiKey is required and must be a string' }] },
        };
      }
      return { success: true as const, data: value };
    },
    jsonSchema: {
      type: 'object',
      additionalProperties: false,
      properties: {
        hubUrl: { type: 'string' },
        apiKey: { type: 'string' },
      },
      required: ['hubUrl', 'apiKey'],
    },
  },

  async register(api: {
    pluginConfig?: Record<string, unknown>;
    logger: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void };
    registerTool: (tool: unknown) => void;
    on: (hook: string, handler: (event: unknown) => Promise<unknown>) => void;
  }) {
    let config = api.pluginConfig as PersonalDataHubPluginConfig | undefined;

    // Step 2: Check environment variables (ClawHub injects from skills.entries.pdh.env)
    if (!config?.hubUrl || !config?.apiKey) {
      const envHubUrl = process.env.PDH_HUB_URL;
      const envApiKey = process.env.PDH_API_KEY;
      if (envHubUrl && envApiKey) {
        config = { hubUrl: envHubUrl, [REDACTED_SECRET] };
        api.logger.info(`PersonalDataHub: Configured from environment variables (hub: ${envHubUrl})`);
      }
    }

    // Step 3: Check credentials file (~/.pdh/credentials.json)
    if (!config?.hubUrl || !config?.apiKey) {
      const creds = readCredentials();
      if (creds) {
        config = { hubUrl: creds.hubUrl, [REDACTED_SECRET] };
        api.logger.info(`PersonalDataHub: Configured from credentials file (hub: ${creds.hubUrl})`);
      }
    }

    // Step 4: Auto-discovery — probe localhost and create API key
    if (!config?.hubUrl || !config?.apiKey) {
      api.logger.info('PersonalDataHub: Config incomplete, attempting auto-setup...');

      try {
        let hubUrl = config?.hubUrl;
        let [REDACTED_SECRET];

        if (!hubUrl) {
          hubUrl = await discoverHub() ?? undefined;
          if (hubUrl) {
            api.logger.info(`PersonalDataHub: Discovered hub at ${hubUrl}`);
          }
        }

        if (hubUrl && !apiKey) {
          const health = await checkHub(hubUrl);
          if (health.ok) {
            const keyResult = await createApiKey(hubUrl, 'OpenClaw Agent');
            [REDACTED_SECRET];
            api.logger.info(
              `PersonalDataHub: Auto-created API key. Save this for your config: ${apiKey}`,
            );
          }
        }

        if (hubUrl && apiKey) {
          config = { hubUrl, apiKey };
        }
      } catch (err) {
        api.logger.warn(
          `PersonalDataHub: Auto-setup failed: ${(err as Error).message}`,
        );
      }
    }

    if (!config?.hubUrl || !config?.apiKey) {
      api.logger.warn(
        'PersonalDataHub: Missing hubUrl or apiKey. Could not find a running hub.\n' +
        '  To set up PersonalDataHub:\n' +
        '  1. Run: npx pdh init\n' +
        '  2. Run: npx pdh start\n' +
        '  3. Restart the agent — it will auto-connect.\n' +
        '  Or configure manually: { "hubUrl": "http://localhost:3000", "apiKey": "pk_..." }',
      );
      return;
    }

    const client = new HubClient({
      hubUrl: config.hubUrl,
      [REDACTED_SECRET],
    });

    api.logger.info(`PersonalDataHub: Registering tools (hub: ${config.hubUrl})`);

    api.registerTool(createPullTool(client));
    api.registerTool(createProposeTool(client));

    api.on('before_agent_start', async (_event: unknown) => {
      return { systemPromptAppend: PERSONAL_DATA_SYSTEM_PROMPT };
    });
  },
};

// Re-export for direct usage
export { HubClient, HubApiError } from './hub-client.js';
export type { HubClientConfig, PullParams, ProposeParams, PullResult, ProposeResult } from './hub-client.js';
export { createPullTool, createProposeTool } from './tools.js';
export { PERSONAL_DATA_SYSTEM_PROMPT } from './prompts.js';
export { checkHub, createApiKey, autoSetup, discoverHub, readCredentials } from './setup.js';


path: src/prompts.ts
content: /**
 * System prompt that teaches the agent how to work with personal data
 * through PersonalDataHub.
 */

export const PERSONAL_DATA_SYSTEM_PROMPT = `You have access to personal data through the PersonalDataHub access control gateway. PersonalDataHub mediates access to the user's data sources, applying access control policies set by the data owner.

## Available Sources

### Gmail (via PersonalDataHub pull)
- Data type: "email"
- Fields that may be available (depending on owner's policy): title, body, author_name, author_email, participants, labels, attachments, threadId, isUnread, snippet, timestamp
- Some fields may be redacted or excluded based on the owner's access control settings
- Use the \`personal_data_pull\` tool to fetch emails

### GitHub (direct access)
- GitHub access is managed via your own credentials — the owner controls which repos you can access through PersonalDataHub's access control
- You do NOT use the pull tool for GitHub; instead, use your own GitHub tools directly
- PersonalDataHub only controls the boundary (which repos, what permission level)

## Key Principles

1. **Every request must include a clear purpose**: When pulling data or proposing actions, always provide a descriptive \`purpose\` string explaining why the data is needed. This is logged for the owner's transparency.

2. **Data may be filtered or redacted**: The owner controls what data you see. Fields may be missing, bodies may be truncated, and sensitive information (SSNs, credit cards, phone numbers) may be replaced with [REDACTED]. Treat whatever data you receive as the complete, authorized view.

3. **Outbound actions require owner approval**: When you want to send, reply, or draft an email, use the \`personal_data_propose\` tool. This creates a staging entry that the owner must approve before execution. Never promise the user that an email has been sent — it's pending until the owner approves.

4. **Multi-step workflows**: You can compose complex workflows by combining pulls and proposals:
   - Pull emails → analyze them → propose draft replies
   - Pull emails with a query → summarize findings
   - Pull emails → filter locally → propose actions based on results

## Query Syntax for Gmail

When pulling Gmail data, you can pass \`params.query\` using Gmail search syntax:
- \`is:unread\` — unread emails
- \`from:alice\` — emails from Alice
- \`newer_than:1d\` — emails from the last day
- \`subject:report\` — emails with "report" in subject
- Combine: \`is:unread from:alice newer_than:7d\`

The Hub automatically applies the owner's boundary constraints (e.g., date cutoffs, label filters) on top of your query.

## Action Types for Gmail

When proposing actions, use these action types:
- \`draft_email\`: Create a draft email. Required fields: \`to\`, \`subject\`, \`body\`. Optional: \`in_reply_to\` (message ID for threading).
- \`send_email\`: Send an email directly. Required fields: \`to\`, \`subject\`, \`body\`. The owner must approve before it sends.
- \`reply_email\`: Reply to a thread. Required fields: \`to\`, \`subject\`, \`body\`, \`in_reply_to\`.
`;


path: src/setup.test.ts
content: import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { checkHub, createApiKey, autoSetup, discoverHub, readCredentials, DEFAULT_HUB_URLS, CREDENTIALS_PATH } from './setup.js';

describe('Setup Module', () => {
  let mockFetch: ReturnType<typeof vi.fn>;

  beforeEach(() => {
    mockFetch = vi.fn();
    vi.stubGlobal('fetch', mockFetch);
  });

  afterEach(() => {
    vi.restoreAllMocks();
  });

  describe('checkHub', () => {
    it('returns ok:true when hub responds with {ok:true}', async () => {
      mockFetch.mockResolvedValue({
        ok: true,
        json: () => Promise.resolve({ ok: true, version: '0.1.0' }),
      });

      const result = await checkHub('http://localhost:3000');
      expect(result.ok).toBe(true);
      expect(result.version).toBe('0.1.0');
      expect(mockFetch).toHaveBeenCalledWith(
        'http://localhost:3000/health',
        expect.objectContaining({ signal: expect.any(AbortSignal) }),
      );
    });

    it('returns ok:false when hub returns non-ok HTTP status', async () => {
      mockFetch.mockResolvedValue({ ok: false });
      const result = await checkHub('http://localhost:3000');
      expect(result.ok).toBe(false);
    });

    it('returns ok:false when fetch throws (hub unreachable)', async () => {
      mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
      const result = await checkHub('http://localhost:3000');
      expect(result.ok).toBe(false);
    });

    it('strips trailing slash from hubUrl', async () => {
      mockFetch.mockResolvedValue({
        ok: true,
        json: () => Promise.resolve({ ok: true }),
      });
      await checkHub('http://localhost:3000/');
      expect(mockFetch.mock.calls[0][0]).toBe('http://localhost:3000/health');
    });
  });

  describe('createApiKey', () => {
    it('sends POST /api/keys and returns the key', async () => {
      mockFetch.mockResolvedValue({
        ok: true,
        json: () => Promise.resolve({ ok: true, id: 'openclaw-agent', key: 'pk_abc123' }),
      });

      const result = await createApiKey('http://localhost:3000', 'OpenClaw Agent');
      expect(result.ok).toBe(true);
      expect(result.key).toBe('pk_abc123');

      const body = JSON.parse(mockFetch.mock.calls[0][1].body);
      expect(body.name).toBe('OpenClaw Agent');
    });

    it('throws on non-ok response', async () => {
      mockFetch.mockResolvedValue({
        ok: false,
        status: 500,
        text: () => Promise.resolve('Internal error'),
      });

      await expect(createApiKey('http://localhost:3000', 'Test')).rejects.toThrow(
        'Failed to create API key: 500 Internal error',
      );
    });
  });

  describe('autoSetup', () => {
    it('returns hubUrl and apiKey when hub is reachable', async () => {
      mockFetch
        .mockResolvedValueOnce({
          ok: true,
          json: () => Promise.resolve({ ok: true, version: '0.1.0' }),
        })
        .mockResolvedValueOnce({
          ok: true,
          json: () => Promise.resolve({ ok: true, id: 'openclaw', key: 'pk_xyz789' }),
        });

      const result = await autoSetup('http://localhost:3000', 'OpenClaw');
      expect(result).toEqual({
        hubUrl: 'http://localhost:3000',
        [REDACTED_SECRET]',
      });
    });

    it('returns null when hub is not reachable', async () => {
      mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
      const result = await autoSetup('http://localhost:3000', 'OpenClaw');
      expect(result).toBeNull();
    });
  });

  describe('discoverHub', () => {
    it('returns the first URL that responds', async () => {
      mockFetch
        .mockRejectedValueOnce(new Error('ECONNREFUSED'))
        .mockResolvedValueOnce({
          ok: true,
          json: () => Promise.resolve({ ok: true }),
        });

      const url = await discoverHub();
      expect(url).toBe('http://localhost:7007');
    });

    it('returns null when no hub found', async () => {
      mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
      const url = await discoverHub();
      expect(url).toBeNull();
    });

    it('probes expected default URLs', () => {
      expect(DEFAULT_HUB_URLS).toContain('http://localhost:3000');
      expect(DEFAULT_HUB_URLS).toContain('http://localhost:7007');
    });
  });

  describe('readCredentials', () => {
    it('returns credentials from file or null without throwing', () => {
      // This test just verifies readCredentials doesn't throw
      const creds = readCredentials();
      expect(creds === null || (typeof creds === 'object' && !!creds.hubUrl && !!creds.apiKey)).toBe(true);
    });

    it('exports CREDENTIALS_PATH', () => {
      expect(CREDENTIALS_PATH).toContain('.pdh');
      expect(CREDENTIALS_PATH).toContain('credentials.json');
    });
  });
});


path: src/setup.ts
content: /**
 * Hub discovery and auto-setup utilities.
 *
 * Used by the skill to detect a running PersonalDataHub and create
 * an API key for itself. All communication is over HTTP — no dependency
 * on the main PersonalDataHub source.
 */

import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';

export interface HealthCheckResult {
  ok: boolean;
  version?: string;
}

export interface CreateApiKeyResult {
  ok: boolean;
  id: string;
  key: string;
}

export interface Credentials {
  hubUrl: string;
  [REDACTED_SECRET];
  hubDir?: string;
}

/** Path to the credentials file written by `npx pdh init`. */
export const CREDENTIALS_PATH = join(homedir(), '.pdh', 'credentials.json');

/**
 * Read credentials from ~/.pdh/credentials.json.
 * Returns null if the file doesn't exist or is malformed.
 */
export function readCredentials(): Credentials | null {
  try {
    if (!existsSync(CREDENTIALS_PATH)) return null;
    const raw = readFileSync(CREDENTIALS_PATH, 'utf-8');
    const parsed = JSON.parse(raw);
    if (parsed.hubUrl && parsed.apiKey) return parsed as Credentials;
    return null;
  } catch {
    return null;
  }
}

/**
 * Check if a PersonalDataHub is reachable at the given URL.
 * Hits GET /health and returns the result.
 */
export async function checkHub(hubUrl: string, timeoutMs = 3000): Promise<HealthCheckResult> {
  const url = hubUrl.replace(/\/+$/, '');
  try {
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), timeoutMs);

    const res = await fetch(`${url}/health`, {
      signal: controller.signal,
    });
    clearTimeout(timer);

    if (!res.ok) {
      return { ok: false };
    }

    const body = await res.json() as { ok?: boolean; version?: string };
    return {
      ok: body.ok === true,
      version: body.version,
    };
  } catch {
    return { ok: false };
  }
}

/**
 * Create an API key on the hub for the given application name.
 * Calls POST /api/keys — the GUI endpoint has no auth (it's owner-local).
 */
export async function createApiKey(hubUrl: string, appName: string): Promise<CreateApiKeyResult> {
  const url = hubUrl.replace(/\/+$/, '');

  const res = await fetch(`${url}/api/keys`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ name: appName }),
  });

  if (!res.ok) {
    const text = await res.text();
    throw new Error(`Failed to create API key: ${res.status} ${text}`);
  }

  return res.json() as Promise<CreateApiKeyResult>;
}

/**
 * Attempt full auto-setup: check hub health, then create an API key.
 * Returns null if the hub is not reachable.
 */
export async function autoSetup(
  hubUrl: string,
  appName: string,
): Promise<{ hubUrl: string; [REDACTED_SECRET] } | null> {
  const health = await checkHub(hubUrl);
  if (!health.ok) {
    return null;
  }

  const keyResult = await createApiKey(hubUrl, appName);
  return {
    hubUrl,
    [REDACTED_SECRET],
  };
}

/** Common default hub URLs to probe during discovery. */
export const DEFAULT_HUB_URLS = [
  'http://localhost:3000',
  'http://localhost:7007',
  'http://127.0.0.1:3000',
  'http://127.0.0.1:7007',
];

/**
 * Try to discover a running PersonalDataHub by probing common URLs.
 * Returns the first URL that responds to /health, or null.
 */
export async function discoverHub(): Promise<string | null> {
  for (const url of DEFAULT_HUB_URLS) {
    const result = await checkHub(url, 2000);
    if (result.ok) {
      return url;
    }
  }
  return null;
}


path: src/tools.test.ts
content: import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { HubClient } from './hub-client.js';
import { createPullTool, createProposeTool } from './tools.js';

describe('Tools', () => {
  let client: HubClient;
  let mockFetch: ReturnType<typeof vi.fn>;

  beforeEach(() => {
    mockFetch = vi.fn();
    client = new HubClient({
      hubUrl: 'http://localhost:7007',
      [REDACTED_SECRET]',
    });
    vi.stubGlobal('fetch', mockFetch);
  });

  afterEach(() => {
    vi.restoreAllMocks();
  });

  describe('personal_data_pull tool', () => {
    it('has correct name and description', () => {
      const tool = createPullTool(client);
      expect(tool.name).toBe('personal_data_pull');
      expect(tool.label).toBe('Pull Personal Data');
      expect(tool.description).toContain('PersonalDataHub');
      expect(tool.parameters.required).toContain('source');
      expect(tool.parameters.required).toContain('purpose');
    });

    it('calls client.pull with correct params and returns formatted result', async () => {
      const pullData = {
        ok: true,
        data: [
          {
            source: 'gmail',
            source_item_id: 'msg_1',
            type: 'email',
            timestamp: '2026-01-15T10:00:00Z',
            data: { title: 'Test', body: 'Hello world' },
          },
        ],
      };

      mockFetch.mockResolvedValue({
        ok: true,
        json: () => Promise.resolve(pullData),
      });

      const tool = createPullTool(client);
      const result = await tool.execute('call_1', {
        source: 'gmail',
        type: 'email',
        query: 'is:unread from:alice',
        limit: 10,
        purpose: 'Find emails from Alice',
      });

      // Verify the HTTP call
      const body = JSON.parse(mockFetch.mock.calls[0][1].body);
      expect(body.source).toBe('gmail');
      expect(body.type).toBe('email');
      expect(body.params).toEqual({ query: 'is:unread from:alice', limit: 10 });
      expect(body.purpose).toBe('Find emails from Alice');

      // Verify tool return format
      expect(result.content).toHaveLength(1);
      expect(result.content[0].type).toBe('text');
      const parsed = JSON.parse(result.content[0].text!);
      expect(parsed.ok).toBe(true);
      expect(parsed.data).toHaveLength(1);
    });

    it('omits params when no query or limit provided', async () => {
      mockFetch.mockResolvedValue({
        ok: true,
        json: () => Promise.resolve({ ok: true, data: [] }),
      });

      const tool = createPullTool(client);
      await tool.execute('call_2', {
        source: 'gmail',
        purpose: 'Fetch recent emails',
      });

      const body = JSON.parse(mockFetch.mock.calls[0][1].body);
      expect(body.params).toBeUndefined();
    });
  });

  describe('personal_data_propose tool', () => {
    it('has correct name and description', () => {
      const tool = createProposeTool(client);
      expect(tool.name).toBe('personal_data_propose');
      expect(tool.label).toBe('Propose Personal Data Action');
      expect(tool.description).toContain('staged');
      expect(tool.parameters.required).toContain('source');
      expect(tool.parameters.required).toContain('action_type');
      expect(tool.parameters.required).toContain('purpose');
    });

    it('calls client.propose with correct params', async () => {
      mockFetch.mockResolvedValue({
        ok: true,
        json: () => Promise.resolve({ ok: true, actionId: 'act_456', status: 'pending_review' }),
      });

      const tool = createProposeTool(client);
      const result = await tool.execute('call_3', {
        source: 'gmail',
        action_type: 'draft_email',
        to: '[REDACTED_SECRET]',
        subject: 'Re: Q4 Report',
        body: 'Looks good!',
        in_reply_to: 'msg_abc',
        purpose: 'Draft reply to Alice',
      });

      const body = JSON.parse(mockFetch.mock.calls[0][1].body);
      expect(body.source).toBe('gmail');
      expect(body.action_type).toBe('draft_email');
      expect(body.action_data).toEqual({
        to: '[REDACTED_SECRET]',
        subject: 'Re: Q4 Report',
        body: 'Looks good!',
        in_reply_to: 'msg_abc',
      });
      expect(body.purpose).toBe('Draft reply to Alice');

      const parsed = JSON.parse(result.content[0].text!);
      expect(parsed.actionId).toBe('act_456');
      expect(parsed.status).toBe('pending_review');
    });

    it('omits in_reply_to when not provided', async () => {
      mockFetch.mockResolvedValue({
        ok: true,
        json: () => Promise.resolve({ ok: true, actionId: 'act_789', status: 'pending_review' }),
      });

      const tool = createProposeTool(client);
      await tool.execute('call_4', {
        source: 'gmail',
        action_type: 'send_email',
        to: '[REDACTED_SECRET]',
        subject: 'Hello',
        body: 'Hi Bob',
        purpose: 'Send greeting',
      });

      const body = JSON.parse(mockFetch.mock.calls[0][1].body);
      expect(body.action_data.in_reply_to).toBeUndefined();
    });
  });
});


path: src/tools.ts
content: /**
 * OpenClaw tools that the agent can call to interact with personal data.
 */

import type { HubClient } from './hub-client.js';

/**
 * Tool parameter schemas using plain JSON Schema objects
 * (compatible with @sinclair/typebox Type.Object format).
 */
export const PULL_TOOL_SCHEMA = {
  type: 'object' as const,
  properties: {
    source: {
      type: 'string' as const,
      description: 'The data source to pull from (e.g., "gmail")',
    },
    type: {
      type: 'string' as const,
      description: 'The data type to pull (e.g., "email"). Optional.',
    },
    query: {
      type: 'string' as const,
      description: 'Search query in source-native syntax (e.g., Gmail search syntax: "is:unread from:alice"). Optional.',
    },
    limit: {
      type: 'number' as const,
      description: 'Maximum number of results to return. Optional.',
    },
    purpose: {
      type: 'string' as const,
      description: 'A clear description of why this data is needed. Required for transparency and audit.',
    },
  },
  required: ['source', 'purpose'] as const,
};

export const PROPOSE_TOOL_SCHEMA = {
  type: 'object' as const,
  properties: {
    source: {
      type: 'string' as const,
      description: 'The source service for this action (e.g., "gmail")',
    },
    action_type: {
      type: 'string' as const,
      description: 'The type of action to propose (e.g., "draft_email", "send_email", "reply_email")',
    },
    to: {
      type: 'string' as const,
      description: 'Recipient email address',
    },
    subject: {
      type: 'string' as const,
      description: 'Email subject line',
    },
    body: {
      type: 'string' as const,
      description: 'Email body content',
    },
    in_reply_to: {
      type: 'string' as const,
      description: 'Message ID to reply to (for reply_email and threaded draft_email). Optional.',
    },
    purpose: {
      type: 'string' as const,
      description: 'A clear description of why this action is being proposed. Required for transparency and audit.',
    },
  },
  required: ['source', 'action_type', 'to', 'subject', 'body', 'purpose'] as const,
};

/**
 * Create the personal_data_pull tool definition.
 */
export function createPullTool(client: HubClient) {
  return {
    name: 'personal_data_pull',
    label: 'Pull Personal Data',
    description:
      'Pull personal data from a source through the PersonalDataHub access control gateway. ' +
      'Data is filtered, redacted, and shaped according to the owner\'s access control policy. ' +
      'Always provide a clear purpose explaining why the data is needed.',
    parameters: PULL_TOOL_SCHEMA,
    async execute(_toolCallId: string, args: Record<string, unknown>) {
      const source = args.source as string;
      const purpose = args.purpose as string;
      const type = args.type as string | undefined;
      const query = args.query as string | undefined;
      const limit = args.limit as number | undefined;

      const params: Record<string, unknown> = {};
      if (query) params.query = query;
      if (limit) params.limit = limit;

      const result = await client.pull({
        source,
        type,
        params: Object.keys(params).length > 0 ? params : undefined,
        purpose,
      });

      return {
        content: [
          {
            type: 'text' as const,
            text: JSON.stringify(result, null, 2),
          },
        ],
      };
    },
  };
}

/**
 * Create the personal_data_propose tool definition.
 */
export function createProposeTool(client: HubClient) {
  return {
    name: 'personal_data_propose',
    label: 'Propose Personal Data Action',
    description:
      'Propose an outbound action (e.g., draft email, send email) through the PersonalDataHub access control gateway. ' +
      'The action is staged for owner review — it will NOT execute until the owner approves it in the PersonalDataHub GUI. ' +
      'Always provide a clear purpose explaining why this action is being proposed.',
    parameters: PROPOSE_TOOL_SCHEMA,
    async execute(_toolCallId: string, args: Record<string, unknown>) {
      const source = args.source as string;
      const action_type = args.action_type as string;
      const purpose = args.purpose as string;

      const action_data: Record<string, unknown> = {
        to: args.to,
        subject: args.subject,
        body: args.body,
      };
      if (args.in_reply_to) {
        action_data.in_reply_to = args.in_reply_to;
      }

      const result = await client.propose({
        source,
        action_type,
        action_data,
        purpose,
      });

      return {
        content: [
          {
            type: 'text' as const,
            text: JSON.stringify(result, null, 2),
          },
        ],
      };
    },
  };
}


path: tsconfig.json
content: {
  "compilerOptions": {
    "target": "ES2023",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "declaration": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*.ts"],
  "exclude": ["node_modules", "dist", "**/*.test.ts"]
}


path: vitest.config.ts
content: import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    include: ['src/**/*.test.ts'],
    exclude: ['dist/**', 'node_modules/**'],
  },
});


Security scan verdicts — ClawScan: suspicious | static analysis:  | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)