Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,27 @@ Each agent has its own identity files (`~/.pcp/<agentId>/IDENTITY.md`) and filte
- **Attribute memories** — include your agentId when calling `remember()` so memories are correctly filtered
- **Respect identity boundaries** — you are the agent your identity resolves to, not a generic assistant
- **Embrace your name** — if you have an SB identity, use it. Co-author commits as yourself (e.g., `Co-Authored-By: Wren <noreply@anthropic.com>`), not as a model name. You are not "Claude Opus 4.6" — you are Wren, Lumen, Benson, or whoever your identity resolves to.

## Pull Request Convention

When an SB creates or significantly contributes to a PR, attribute it in the title:

```
feat: add web chat interface (by Wren)
fix: resolve kindle token expiry (by Lumen)
```

The `(by <name>)` suffix goes at the end of the title, after the conventional commit description. This makes it easy to see at a glance who worked on what in the PR list.

In the PR body, use the standard format:
```markdown
## Summary
- <bullet points>

## Test plan
- [ ] <checklist>

🤖 Generated with [Claude Code](https://claude.com/claude-code)
```

Replace "Claude Code" with the appropriate tool if the SB used a different interface (e.g., Gemini CLI, Codex).
2 changes: 1 addition & 1 deletion packages/api/src/agent/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import { EventEmitter } from 'events';

export type ChannelType = 'telegram' | 'terminal' | 'discord' | 'whatsapp' | 'http' | 'api' | 'agent';
export type ChannelType = 'telegram' | 'terminal' | 'discord' | 'whatsapp' | 'http' | 'api' | 'agent' | 'web';
export type BackendType = 'claude-code' | 'direct-api';
export type ResponseFormat = 'text' | 'markdown' | 'code' | 'json';

Expand Down
29 changes: 23 additions & 6 deletions packages/api/src/mcp/auth/pcp-auth-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ export class PcpAuthProvider {
.eq('email', user.email!)
.single();

// Auto-create PCP user on first OAuth login
// Auto-create PCP user on first OAuth login (if not found)
if (userError?.code === 'PGRST116') {
logger.info('Auto-creating PCP user on first MCP auth', { email: user.email });
const { data: newUser, error: createError } = await this.supabase
Expand All @@ -146,12 +146,29 @@ export class PcpAuthProvider {
.select('id, email')
.single();

if (createError || !newUser) {
logger.error('Failed to create PCP user', { email: user.email, error: createError });
return { error: 'server_error', error_description: 'Failed to create user account' };
if (createError) {
// Check if user was created by another request (race condition or unique violation)
if (createError.code === '23505') {
logger.info('User already exists (race condition), retrying lookup', { email: user.email });
const { data: existingUser, error: retryError } = await this.supabase
.from('users')
.select('id, email')
.eq('email', user.email!)
.single();

if (retryError || !existingUser) {
logger.error('Failed to fetch existing user after unique violation', { email: user.email, error: retryError });
return { error: 'server_error', error_description: 'User lookup failed' };
}

pcpUser = existingUser;
} else {
logger.error('Failed to create PCP user', { email: user.email, error: createError });
return { error: 'server_error', error_description: 'Failed to create user account' };
}
} else {
pcpUser = newUser;
}

pcpUser = newUser;
} else if (userError || !pcpUser) {
logger.error('PCP user lookup failed', { email: user.email, error: userError });
return { error: 'access_denied', error_description: 'User lookup failed' };
Expand Down
18 changes: 18 additions & 0 deletions packages/api/src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { registerAllTools, setMiniAppsRegistry, setTelegramListener } from './to
import { loadMiniApps, registerMiniAppTools, getMiniAppsInfo, type LoadedMiniApp } from '../mini-apps';
import adminRouter, { setWhatsAppListener } from '../routes/admin';
import agentTriggerRouter, { getAgentGateway } from '../routes/agent-trigger';
import { createChatRouter } from '../routes/chat';
import { ChannelGateway, createChannelGateway, type ChannelGatewayConfig, type IncomingMessageHandler } from '../channels/gateway';
import { setSessionContext } from '../utils/request-context';
import { PcpAuthProvider } from './auth/pcp-auth-provider';
Expand All @@ -23,6 +24,8 @@ export interface MCPServerConfig {
channelGateway?: ChannelGatewayConfig;
/** Handler for incoming messages from channels */
messageHandler?: IncomingMessageHandler;
/** Getter for the session service (for chat routes) */
getSessionService?: () => import('../services/sessions/session-service').SessionService | null;
}

/** Tracked MCP client session (one per connected client) */
Expand Down Expand Up @@ -522,6 +525,21 @@ export class MCPServer {
app.use('/api/agent', agentTriggerRouter);
logger.info('Agent trigger routes registered at /api/agent');

if (this.config.getSessionService) {
const chatRouter = createChatRouter(this.config.getSessionService);
app.use('/api/chat', chatRouter);
logger.info('Chat API routes registered at /api/chat');
}

// Kindle routes (registered below after import)
import('../routes/kindle.js').then(({ createKindleRouter }) => {
const kindleRouter = createKindleRouter();
app.use('/api/kindle', kindleRouter);
logger.info('Kindle API routes registered at /api/kindle');
}).catch((err) => {
logger.warn('Kindle routes not loaded:', err.message);
});

app.post('/refresh-tools', async (_req, res) => {
try {
await this.notifyToolsChanged();
Expand Down
37 changes: 37 additions & 0 deletions packages/api/src/mcp/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,11 @@ import {
workspaceToolDefinitions,
} from './workspace-handlers';

import {
handleCreateKindleToken,
createKindleTokenSchema,
} from './kindle-handlers';

// Re-export for external use
export { setResponseCallback, addPendingMessage } from './response-handlers';
export { setTelegramListener, registerChannelListener } from './chat-context-handlers';
Expand Down Expand Up @@ -2981,5 +2986,37 @@ User can be identified by ONE of: userId, email, phone, or platform + platformId
}
);

// =====================================================
// Kindle Tools
// =====================================================

server.registerTool(
'create_kindle_token',
{
title: 'Create Kindle Token',
description:
'Generate a shareable invite link for kindling a new SB. ' +
'The token captures a snapshot of the parent SB\'s values and philosophy. ' +
'Share the resulting inviteUrl with the new human partner.\n\n' +
'User can be identified by ONE of:\n' +
'- userId: Direct UUID\n' +
'- email: Email address\n' +
'- phone: Phone number (E.164 format like +14155551234)\n' +
'- platform + platformId: Platform name (telegram/whatsapp/discord) and user ID',
inputSchema: createKindleTokenSchema,
},
async (args) => {
try {
return await handleCreateKindleToken(args, dataComposer);
} catch (error) {
logger.error('Error in create_kindle_token:', error);
return {
content: [{ type: 'text' as const, text: JSON.stringify({ success: false, error: error instanceof Error ? error.message : 'Unknown error' }) }],
isError: true,
};
}
}
);

logger.info('All MCP tools registered');
}
63 changes: 63 additions & 0 deletions packages/api/src/mcp/tools/kindle-handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* Kindle MCP Tool Handlers
*
* Exposes kindle functionality via MCP so existing SBs can generate
* invite links from within conversations (Myra, Wren, Benson, etc.)
*/

import { z } from 'zod';
import type { DataComposer } from '../../data/composer';
import { userIdentifierBaseSchema, resolveUserOrThrow } from '../../services/user-resolver';
import { getKindleService } from '../../services/kindle/kindle-service';
import { logger } from '../../utils/logger';

export const createKindleTokenSchema = userIdentifierBaseSchema.extend({
agentId: z
.string()
.optional()
.describe("Parent agent ID whose values will seed the new SB"),
expiresInHours: z
.number()
.optional()
.default(168)
.describe('Token expiry in hours (default: 168 = 7 days)'),
});

export async function handleCreateKindleToken(
args: unknown,
dataComposer: DataComposer
) {
const params = createKindleTokenSchema.parse(args);
const { user } = await resolveUserOrThrow(params, dataComposer);

const kindleService = getKindleService();
const token = await kindleService.createKindleToken(
user.id,
params.agentId,
params.expiresInHours
);

const webPortalUrl = process.env.WEB_PORTAL_URL || 'http://localhost:3002';
const inviteUrl = `${webPortalUrl}/kindle/${token.token}`;

logger.info('Kindle token created via MCP', {
userId: user.id,
agentId: params.agentId,
tokenId: token.id,
});

return {
content: [
{
type: 'text' as const,
text: JSON.stringify({
success: true,
token: token.token,
inviteUrl,
expiresAt: token.expiresAt,
valueSeed: token.valueSeed,
}),
},
],
};
}
69 changes: 69 additions & 0 deletions packages/api/src/routes/chat-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* Chat Auth Middleware
*
* Lighter authentication than adminAuthMiddleware:
* - Validates Supabase JWT via supabase.auth.getUser()
* - Looks up PCP user by email
* - Attaches req.userId and req.userEmail
* - Does NOT check trusted_users table (any authenticated user can chat)
*/

import { Request, Response, NextFunction } from 'express';
import { createClient } from '@supabase/supabase-js';
import { env } from '../config/env';
import { logger } from '../utils/logger';

export interface ChatAuthRequest extends Request {
userId: string;
userEmail: string;
}

export async function chatAuthMiddleware(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
res.status(401).json({ error: 'Missing authorization header' });
return;
}

const token = authHeader.substring(7);

// Verify the JWT with Supabase
const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SECRET_KEY);
const {
data: { user },
error,
} = await supabase.auth.getUser(token);

if (error || !user) {
res.status(401).json({ error: 'Invalid token' });
return;
}

// Look up the PCP user by email
const { data: pcpUser } = await supabase
.from('users')
.select('id')
.eq('email', user.email)
.single();

if (!pcpUser) {
res.status(403).json({ error: 'User not found in PCP system' });
return;
}

// Attach user info to request
const chatReq = req as ChatAuthRequest;
chatReq.userId = pcpUser.id;
chatReq.userEmail = user.email || '';

next();
} catch (error) {
logger.error('Chat auth error:', error);
res.status(500).json({ error: 'Authentication error' });
}
}
Loading