Agent skill
exposing-apis-to-widgets
Exposing StickerNest APIs to widgets via the Protocol. Use when the user asks about widget API, widget requests, social API for widgets, widget permissions, widget:request, or how widgets call backend services. Covers Protocol messages, request handling, and permission system.
Install this agent skill to your Project
npx add-skill https://github.com/majiayu000/claude-skill-registry/tree/main/skills/devops/exposing-apis-to-widgets
SKILL.md
Exposing APIs to Widgets
This skill covers how to expose StickerNest services to widgets through the Widget Protocol, enabling widgets to access social features, data, and actions securely.
Architecture Overview
┌─────────────────────────────────────────────────────────────┐
│ Widget (iframe) │
│ │
│ WidgetAPI.request('social:getFeed', { type: 'public' }) │
│ │ │
└────────────────────────────┼────────────────────────────────┘
│ postMessage
▼
┌─────────────────────────────────────────────────────────────┐
│ WidgetHost │
│ │
│ 1. Validate permission (social:read) │
│ 2. Route to handler │
│ 3. Call service (FeedService.getGlobalFeed) │
│ 4. Return result │
│ │ │
└────────────────────────────┼────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Services │
│ FeedService │ ChatService │ SocialGraphService │
└─────────────────────────────────────────────────────────────┘
Protocol Message Types
Widget → Host: Request
// Widget sends
window.parent.postMessage({
type: 'widget:request',
payload: {
requestId: 'req-123', // Unique ID for matching response
action: 'social:getFeed',
data: { type: 'public', limit: 20 },
},
}, '*');
Host → Widget: Response
// Host responds
iframe.contentWindow.postMessage({
type: 'widget:response',
payload: {
requestId: 'req-123', // Matches request
result: { activities: [...] },
error: null, // Or error message if failed
},
}, '*');
Widget-Side API
Request Helper
// In widget code
const WidgetAPI = {
pendingRequests: new Map(),
request(action, data = {}) {
return new Promise((resolve, reject) => {
const requestId = `req-${Date.now()}-${Math.random().toString(36).slice(2)}`;
// Store resolver
this.pendingRequests.set(requestId, { resolve, reject });
// Send request
window.parent.postMessage({
type: 'widget:request',
payload: { requestId, action, data },
}, '*');
// Timeout after 30 seconds
setTimeout(() => {
if (this.pendingRequests.has(requestId)) {
this.pendingRequests.delete(requestId);
reject(new Error('Request timeout'));
}
}, 30000);
});
},
handleResponse(payload) {
const { requestId, result, error } = payload;
const pending = this.pendingRequests.get(requestId);
if (pending) {
this.pendingRequests.delete(requestId);
if (error) {
pending.reject(new Error(error));
} else {
pending.resolve(result);
}
}
},
// Convenience methods
async getFeed(type = 'public', limit = 20) {
return this.request('social:getFeed', { type, limit });
},
async follow(userId) {
return this.request('social:follow', { userId });
},
async sendMessage(channelId, content) {
return this.request('social:sendMessage', { channelId, content });
},
async getProfile(userId) {
return this.request('social:getProfile', { userId });
},
};
// Listen for responses
window.addEventListener('message', (event) => {
if (event.data?.type === 'widget:response') {
WidgetAPI.handleResponse(event.data.payload);
}
});
Host-Side Request Handler
WidgetHost Implementation
// src/runtime/WidgetHost.ts
import { FeedService } from '@/services/social/FeedService';
import { ChatService } from '@/services/social/ChatService';
import { SocialGraphService } from '@/services/social/SocialGraphService';
import { ProfileService } from '@/services/social/ProfileService';
import { NotificationService } from '@/services/social/NotificationService';
interface WidgetRequest {
requestId: string;
action: string;
data: Record<string, any>;
}
class WidgetHost {
private manifest: WidgetManifest;
private iframe: HTMLIFrameElement;
private authContext: AuthContextType;
constructor(manifest: WidgetManifest, iframe: HTMLIFrameElement, auth: AuthContextType) {
this.manifest = manifest;
this.iframe = iframe;
this.authContext = auth;
window.addEventListener('message', this.handleMessage.bind(this));
}
private handleMessage(event: MessageEvent) {
// Verify origin matches iframe
if (event.source !== this.iframe.contentWindow) return;
const { type, payload } = event.data || {};
if (type === 'widget:request') {
this.handleRequest(payload as WidgetRequest);
}
}
private async handleRequest(request: WidgetRequest) {
const { requestId, action, data } = request;
try {
// Check permission
if (!this.hasPermission(action)) {
throw new Error(`Permission denied: ${action}`);
}
// Route to handler
const result = await this.routeAction(action, data);
// Send success response
this.sendResponse(requestId, result, null);
} catch (err) {
// Send error response
this.sendResponse(requestId, null, err.message);
}
}
private hasPermission(action: string): boolean {
const permissions = this.manifest.permissions || [];
// Map actions to required permissions
const permissionMap: Record<string, string> = {
'social:getFeed': 'social:read',
'social:getProfile': 'social:read',
'social:getMessages': 'social:read',
'social:getNotifications': 'social:read',
'social:follow': 'social:write',
'social:unfollow': 'social:write',
'social:sendMessage': 'social:write',
'social:markRead': 'social:write',
'canvas:getData': 'canvas:read',
'canvas:setData': 'canvas:write',
'storage:get': 'storage:read',
'storage:set': 'storage:write',
};
const required = permissionMap[action];
if (!required) return false;
return permissions.includes(required) || permissions.includes('*');
}
private async routeAction(action: string, data: any): Promise<any> {
// Require authentication for most actions
if (action.startsWith('social:') && !this.authContext.isAuthenticated) {
throw new Error('Authentication required');
}
switch (action) {
// === Feed API ===
case 'social:getFeed':
return this.handleGetFeed(data);
// === Profile API ===
case 'social:getProfile':
return ProfileService.getProfile(data.userId);
case 'social:searchProfiles':
return ProfileService.searchProfiles(data.query, data.limit);
// === Social Graph API ===
case 'social:follow':
return SocialGraphService.followUser(data.userId);
case 'social:unfollow':
return SocialGraphService.unfollowUser(data.userId);
case 'social:getFollowers':
return SocialGraphService.getFollowers(data.userId, data.limit);
case 'social:getFollowing':
return SocialGraphService.getFollowing(data.userId, data.limit);
case 'social:checkFollowing':
return SocialGraphService.checkIsFollowing(data.userId);
// === Chat API ===
case 'social:getMessages':
return ChatService.getMessages(data.channelId, data.limit, data.before);
case 'social:sendMessage':
return ChatService.sendMessage(data.channelId, data.content, data.replyTo);
case 'social:deleteMessage':
return ChatService.deleteMessage(data.messageId);
// === Notification API ===
case 'social:getNotifications':
return NotificationService.getNotifications(data.limit);
case 'social:markRead':
return NotificationService.markAsRead(data.notificationId);
case 'social:markAllRead':
return NotificationService.markAllAsRead();
// === Presence API ===
case 'social:getOnlineUsers':
return this.getOnlineUsers(data.canvasId);
// === Storage API ===
case 'storage:get':
return this.getWidgetStorage(data.key);
case 'storage:set':
return this.setWidgetStorage(data.key, data.value);
default:
throw new Error(`Unknown action: ${action}`);
}
}
private async handleGetFeed(data: { type: string; limit?: number; offset?: number }) {
const { type, limit = 20, offset = 0 } = data;
switch (type) {
case 'public':
return FeedService.getGlobalFeed(limit, offset);
case 'friends':
return FeedService.getFriendsFeed(limit, offset);
case 'user':
return FeedService.getUserFeed(data.userId, limit, offset);
default:
throw new Error(`Unknown feed type: ${type}`);
}
}
private sendResponse(requestId: string, result: any, error: string | null) {
this.iframe.contentWindow?.postMessage({
type: 'widget:response',
payload: { requestId, result, error },
}, '*');
}
// Widget-specific storage
private async getWidgetStorage(key: string): Promise<any> {
const storageKey = `widget:${this.manifest.id}:${key}`;
const value = localStorage.getItem(storageKey);
return value ? JSON.parse(value) : null;
}
private async setWidgetStorage(key: string, value: any): Promise<void> {
const storageKey = `widget:${this.manifest.id}:${key}`;
localStorage.setItem(storageKey, JSON.stringify(value));
}
}
Permission System
Manifest Permissions
{
"id": "my-social-widget",
"permissions": [
"social:read", // Read feeds, profiles, messages
"social:write", // Follow, send messages, etc.
"social:subscribe", // Subscribe to realtime events
"storage:read", // Read widget storage
"storage:write", // Write widget storage
"canvas:read", // Read canvas data
"canvas:write" // Modify canvas (admin widgets only)
]
}
Permission Categories
| Permission | Actions Allowed |
|---|---|
social:read |
getFeed, getProfile, getMessages, getFollowers |
social:write |
follow, unfollow, sendMessage, markRead |
social:subscribe |
Subscribe to realtime events |
storage:read |
Get widget-scoped storage |
storage:write |
Set widget-scoped storage |
canvas:read |
Read canvas metadata, widget list |
canvas:write |
Add/remove widgets, modify canvas |
* |
All permissions (dangerous!) |
Permission Request Flow
// Widget can check if it has permission
async function checkPermission(permission) {
const result = await WidgetAPI.request('system:checkPermission', { permission });
return result.granted;
}
// Host grants based on manifest
case 'system:checkPermission':
return {
granted: this.manifest.permissions?.includes(data.permission) ?? false,
};
Real-time Event Subscriptions
Subscribing to Events
// Widget requests subscription
await WidgetAPI.request('social:subscribe', {
events: ['social:message-new', 'social:notification-new'],
});
// Host enables event forwarding
case 'social:subscribe':
data.events.forEach(eventName => {
this.subscribedEvents.add(eventName);
});
return { subscribed: data.events };
Forwarding Events to Widget
// In WidgetHost
private setupEventForwarding() {
this.eventBus.on('social:*', (event, payload) => {
if (this.subscribedEvents.has(event)) {
this.iframe.contentWindow?.postMessage({
type: 'widget:event',
payload: { type: event, payload },
}, '*');
}
});
}
API Reference
Feed API
| Action | Data | Returns |
|---|---|---|
social:getFeed |
{ type, limit?, offset? } |
{ activities: Activity[] } |
Profile API
| Action | Data | Returns |
|---|---|---|
social:getProfile |
{ userId } |
{ profile: Profile } |
social:searchProfiles |
{ query, limit? } |
{ profiles: Profile[] } |
Social Graph API
| Action | Data | Returns |
|---|---|---|
social:follow |
{ userId } |
{ success: boolean } |
social:unfollow |
{ userId } |
{ success: boolean } |
social:getFollowers |
{ userId, limit? } |
{ users: Profile[] } |
social:getFollowing |
{ userId, limit? } |
{ users: Profile[] } |
social:checkFollowing |
{ userId } |
{ isFollowing: boolean } |
Chat API
| Action | Data | Returns |
|---|---|---|
social:getMessages |
{ channelId, limit?, before? } |
{ messages: Message[] } |
social:sendMessage |
{ channelId, content, replyTo? } |
{ message: Message } |
social:deleteMessage |
{ messageId } |
{ success: boolean } |
Notification API
| Action | Data | Returns |
|---|---|---|
social:getNotifications |
{ limit? } |
{ notifications: Notification[] } |
social:markRead |
{ notificationId } |
{ success: boolean } |
social:markAllRead |
{} |
{ success: boolean } |
Storage API
| Action | Data | Returns |
|---|---|---|
storage:get |
{ key } |
{ value: any } |
storage:set |
{ key, value } |
{ success: boolean } |
Error Handling
Standard Error Responses
// Permission denied
{ error: 'Permission denied: social:write' }
// Not authenticated
{ error: 'Authentication required' }
// Invalid action
{ error: 'Unknown action: invalid:action' }
// Service error
{ error: 'Failed to fetch feed: Network error' }
// Validation error
{ error: 'Invalid userId format' }
Widget Error Handling
try {
const result = await WidgetAPI.getFeed('public');
renderFeed(result.activities);
} catch (err) {
if (err.message.includes('Permission denied')) {
showPermissionError();
} else if (err.message.includes('Authentication required')) {
showLoginPrompt();
} else {
showGenericError(err.message);
}
}
Security Best Practices
- Always validate permissions - Check manifest before routing
- Never expose tokens - Only pass user ID, not session
- Scope storage by widget -
widget:{id}:{key} - Rate limit requests - Prevent abuse
- Validate all input - Sanitize data from widgets
- Log sensitive actions - Audit trail for writes
- Use allowlist for actions - Reject unknown actions
Reference Files
| File | Purpose |
|---|---|
src/runtime/WidgetHost.ts |
Request routing and handling |
src/runtime/WidgetSandbox.ts |
Iframe sandboxing |
src/types/widget.ts |
Widget manifest types |
src/services/social/ |
Backend services |
Adding New APIs
Step 1: Define Permission
// In permissionMap
'myfeature:getData': 'myfeature:read',
'myfeature:setData': 'myfeature:write',
Step 2: Add Route Handler
case 'myfeature:getData':
return MyService.getData(data.id);
case 'myfeature:setData':
return MyService.setData(data.id, data.value);
Step 3: Document in Widget
// In widget WidgetAPI
async getMyData(id) {
return this.request('myfeature:getData', { id });
}
Recommended Agent Skills
Expand your agent's capabilities with these related and highly-rated skills.
agent-ops-spec
Manage specification documents in .agent/specs/. Use when user provides requirements, acceptance criteria, or feature descriptions that need to be tracked and validated against implementation.
agent-ops-state
Maintain .agent state files. Use at session start, after meaningful steps, and before concluding: read/update constitution/memory/focus/issues/baseline consistently.
agent-ops-spec
Manage specification documents in .agent/specs/. Use when user provides requirements, acceptance criteria, or feature descriptions that need to be tracked and validated against implementation.
agent-ops-testing
Test strategy, execution, and coverage analysis. Use when designing tests, running test suites, or analyzing test results beyond baseline checks.
agent-ops-testing
Test strategy, execution, and coverage analysis. Use when designing tests, running test suites, or analyzing test results beyond baseline checks.
agent-ops-state
Maintain .agent state files. Use at session start, after meaningful steps, and before concluding: read/update constitution/memory/focus/issues/baseline consistently.
Didn't find tool you were looking for?